diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
new file mode 100644
index 0000000..c6bb036
--- /dev/null
+++ b/.github/workflows/maven.yml
@@ -0,0 +1,35 @@
+# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven
+
+# This workflow uses actions that are not certified by GitHub.
+# They are provided by a third-party and are governed by
+# separate terms of service, privacy policy, and support
+# documentation.
+
+name: Java CI with Maven
+
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: maven
+ - name: Build with Maven
+ run: mvn -B package --file pom.xml
+
+ # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive
+ - name: Update dependency graph
+ uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6
diff --git a/.gitignore b/.gitignore
index e24253b..21c246c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ target
*.iml
.classpath
.project
+.settings
diff --git a/README.md b/README.md
index 1ed37c3..d2f3924 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,25 @@
# sham.software Mock SSH API
-Mock SSH and SFTP testing library for running an SSH server in process. SFTP uses a local temporary directory
+Mock SSH and SFTP testing library for running an SSH server in process. SFTP uses a local temporary directory.
-This library is still under development. Feel free to use, contribute but there could be changes to
-the API until it reaches 1.0 status. Of course, we'll try to keep breaking changes to a minimum.
+## Development Notes
+
+
+04.02.2025 1.0.0-RC2 is ready
+* restored sftp
+* introduced git-pack
+
+02.02.2025 1.0.0-RC1 is ready
+* swapping maverick-synergy-client for JSch (for testing)
+* completing the mocking framework to survive basic checks from ssh-client
+(greeting, prompt and echo-ing)
+* support command or shell mode exclusively
+
+25.01.2025 0.4.0 is ready
+* revamped with latest versions of sshd-core and sshd-sftp.
+
+Hope to have soon artifacts on MC.
**Table of Contents**
@@ -16,11 +31,17 @@ the API until it reaches 1.0 status. Of course, we'll try to keep breaking chang
### Maven
+```shell
+git checkout https://github.com/janesser/sham-ssh
+cd sham-ssh
+mvn install
+```
+
```xml
software.sham
sham-ssh
- 0.1.0
+ 1.0.0-SNAPSHOT
```
diff --git a/pom.xml b/pom.xml
index aed2f34..0f48ac8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,239 +1,254 @@
-
- 4.0.0
+
+ 4.0.0
- software.sham
- sham-ssh
- 0.1.0
- jar
- Mock SSH and SFTP Testing API
- http://sham.software/ssh
- Testing library for mocking interactions to SSH and SFTP servers
-
- scm:git:git@github.com:shamsoftware/sham-ssh.git
- scm:git:git@github.com:shamsoftware/sham-ssh.git
- scm:git:git@github.com:shamsoftware/sham-ssh.git
-
-
-
- The Apache Software License, Version 2.0
- http://www.apache.org/licenses/LICENSE-2.0.txt
- repo
-
-
-
-
- rhoegg
- Ryan Hoegg
- ryan.hoegg@gmail.com
-
-
-
-
- sonatype-nexus-snapshots
- Sonatype Nexus Snapshots
- https://oss.sonatype.org/content/repositories/snapshots/
-
-
- sonatype-nexus-staging
- Nexus Release Repository
- https://oss.sonatype.org/service/local/staging/deploy/maven2/
-
-
-
- UTF-8
- UTF-8
- 1.7
- 2.1
- 1.7.7
-
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
- 3.3
-
- ${jdk.version}
- ${jdk.version}
- ISO-8859-1
-
-
-
- org.apache.maven.plugins
- maven-source-plugin
- 2.4
-
-
- attach-sources
-
- jar
-
-
-
-
-
-
-
-
-
-
- org.apache.logging.log4j
- log4j-api
- ${log4j.version}
- true
-
-
- org.apache.logging.log4j
- log4j-core
- ${log4j.version}
- true
-
-
- org.apache.logging.log4j
- log4j-slf4j-impl
- ${log4j.version}
- true
-
-
- org.slf4j
- jcl-over-slf4j
- ${sl4fj.version}
- true
-
-
- org.slf4j
- slf4j-api
- ${sl4fj.version}
-
+ software.sham
+ sham-ssh
+ 1.0.0-SNAPSHOT
+ jar
+ Mock SSH and SFTP Testing API
+ http://sham.software/ssh
+ Testing library for mocking interactions to SSH and SFTP
+ servers
+
+ scm:git:git@github.com:shamsoftware/sham-ssh.git
+ scm:git:git@github.com:shamsoftware/sham-ssh.git
+ scm:git:git@github.com:shamsoftware/sham-ssh.git
+
+
+
+ The Apache Software License, Version 2.0
+ http://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+ rhoegg
+ Ryan Hoegg
+ ryan.hoegg@gmail.com
+
+
+ janesser
+ Jan Esser
+ jesser@gmx.de
+
+
+
+
+ sonatype-nexus-snapshots
+ Sonatype Nexus Snapshots
+ https://oss.sonatype.org/content/repositories/snapshots/
+
+
+ sonatype-nexus-staging
+ Nexus Release Repository
+ https://oss.sonatype.org/service/local/staging/deploy/maven2/
+
+
+
+ UTF-8
+ UTF-8
+ 8
+ 2.14.0
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ ${jdk.version}
+ ${jdk.version}
+ ISO-8859-1
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.4.0
+
+
+ attach-sources
+
+ jar
+
+
+
+
+
+
+
+
+
+ org.apache.sshd
+ sshd-core
+ ${sshd.version}
+
+
+ org.apache.sshd
+ sshd-sftp
+ ${sshd.version}
+
+
+ org.apache.sshd
+ sshd-git
+ ${sshd.version}
+
+
+ org.hamcrest
+ hamcrest-core
+ 3.0
+
+
+
+ net.i2p.crypto
+ eddsa
+ 0.3.0
+
-
-
- junit
- junit
- 4.12
-
- compile
- true
-
-
- org.mockito
- mockito-core
- 1.9.5
- test
-
-
-
-
-
-
- org.hamcrest
- hamcrest-core
- 1.3
-
-
- org.hamcrest
- hamcrest-library
- 1.3
-
-
- org.apache.sshd
- sshd-core
- 1.0.0
-
-
- com.jcraft
- jsch
- 0.1.53
-
-
-
- org.bouncycastle
- bcpkix-jdk15on
- 1.49
-
-
- commons-io
- commons-io
- 2.4
-
+
+
+ org.slf4j
+ slf4j-api
+ 2.0.16
+
+
+ org.slf4j
+ slf4j-simple
+ 2.0.16
+
-
-
- org.apache.logging.log4j
- log4j-api
-
-
- org.apache.logging.log4j
- log4j-core
-
-
- org.apache.logging.log4j
- log4j-slf4j-impl
-
-
- org.slf4j
- jcl-over-slf4j
-
-
- org.slf4j
- slf4j-api
-
-
- com.lmax
- disruptor
- 3.3.2
- true
-
+
+
+ com.sshtools
+ maverick-synergy-client
+ 3.1.2
+
+
+ org.bouncycastle
+ bcpg-jdk18on
+ 1.80
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ 1.80
+
+
+ junit
+ junit
+ 4.13.2
+
+
+ org.awaitility
+ awaitility
+ 4.2.2
+
+
+
+
+
+ org.apache.sshd
+ sshd-core
+
+
+ org.apache.sshd
+ sshd-sftp
+ true
+
+
+ org.apache.sshd
+ sshd-git
+ true
+
+
+ org.hamcrest
+ hamcrest-core
+
+
+ net.i2p.crypto
+ eddsa
+ true
+
+
+ org.slf4j
+ slf4j-api
+
-
-
- junit
- junit
-
-
- org.mockito
- mockito-core
-
-
-
-
-
- sonatype
-
-
- sonatype-public
- Sonatype OSS Maven Repo (snapshots)
- https://oss.sonatype.org/content/groups/public
-
- true
-
-
- true
-
-
-
-
-
-
- org.apache.maven.plugins
- maven-gpg-plugin
- 1.4
-
-
- sign-artifacts
- install
-
- sign
-
-
-
-
- ${sham.keyname}
-
-
-
-
-
-
+
+
+ com.sshtools
+ maverick-synergy-client
+ test
+
+
+ org.bouncycastle
+ bcpg-jdk18on
+ test
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ test
+
+
+ junit
+ junit
+ test
+
+
+ org.slf4j
+ slf4j-simple
+ test
+
+
+ org.awaitility
+ awaitility
+ test
+
+
+
+
+
+ sonatype
+
+
+ sonatype-public
+ Sonatype OSS Maven Repo (snapshots)
+ https://oss.sonatype.org/content/groups/public
+
+ true
+
+
+ true
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 1.4
+
+
+ sign-artifacts
+ install
+
+ sign
+
+
+
+
+ ${sham.keyname}
+
+
+
+
+
+
diff --git a/src/main/java/software/sham/git/MockGitServer.java b/src/main/java/software/sham/git/MockGitServer.java
new file mode 100644
index 0000000..d6d25e4
--- /dev/null
+++ b/src/main/java/software/sham/git/MockGitServer.java
@@ -0,0 +1,69 @@
+package software.sham.git;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.sshd.git.GitLocationResolver;
+import org.apache.sshd.git.pack.GitPackCommandFactory;
+
+import software.sham.ssh.MockSshServer;
+
+public class MockGitServer extends MockSshServer {
+
+ private static final String MOCK_GIT_DIRECTORY = "mock_git_root";
+ private Path gitRoot;
+
+ public MockGitServer(int port) throws IOException {
+ super(port, false);
+
+ gitRoot = Files.createTempDirectory(MOCK_GIT_DIRECTORY);
+ gitRoot.toFile().deleteOnExit();
+
+ GitPackCommandFactory commandFactory = new GitPackCommandFactory()
+ .withGitLocationResolver(GitLocationResolver.constantPath(gitRoot));
+ super.getSshServer().setCommandFactory(commandFactory);
+ }
+
+ public Path prepareGitProject(String name) throws IOException, InterruptedException {
+ Path gitProject = Files.createDirectories(gitRoot.resolve(name));
+
+ runGit(gitProject, new String[] { "/usr/bin/git", "init" }, //
+ "Couldn't initialiaze project at: " + gitProject);
+ runGit(gitProject, new String[] { "/usr/bin/git", "config", "user.email", "tester@localhost" },
+ "Couldn't config user.email.");
+ runGit(gitProject, new String[] { "/usr/bin/git", "config", "user.name", "the tester" },
+ "Couldn't config user.name.");
+
+ Path readMeFile = gitProject.resolve("README");
+ if (!Files.exists(readMeFile))
+ Files.createFile(readMeFile);
+ Files.writeString(readMeFile, "nothing serious");
+ runGit(gitProject, new String[] { "/usr/bin/git", "add", gitProject.relativize(readMeFile).toString() }, //
+ "Couldn't add README.");
+
+ runGit(gitProject, new String[] { "/usr/bin/git", "commit", "-m", "'Initial commit.'" }, //
+ "Couldn't commit.");
+
+ return gitProject;
+ }
+
+ private void runGit(Path gitProject, String[] cmd, String errorMsg) throws InterruptedException, IOException {
+ ProcessBuilder pb = new ProcessBuilder(cmd);
+ pb.directory(gitProject.toFile());
+ pb.redirectErrorStream(true);
+ Process p = pb.start();
+ String processOutput = readProcessOutput(p);
+ if (p.exitValue() != 0) {
+ throw new RuntimeException(errorMsg + processOutput);
+ }
+
+ }
+
+ private String readProcessOutput(Process p) throws IOException, InterruptedException {
+ BufferedReader reader = p.inputReader();
+ String processOutput = reader.lines().reduce("", (a, b) -> a + "\n" + b);
+ return String.format("\n(returncode: %d) %s", p.waitFor(), processOutput);
+ }
+}
diff --git a/src/main/java/software/sham/sftp/MockSftpServer.java b/src/main/java/software/sham/sftp/MockSftpServer.java
index ddd240c..afb099a 100644
--- a/src/main/java/software/sham/sftp/MockSftpServer.java
+++ b/src/main/java/software/sham/sftp/MockSftpServer.java
@@ -1,66 +1,44 @@
package software.sham.sftp;
-import org.apache.commons.io.FileUtils;
-import org.apache.sshd.common.NamedFactory;
-import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
-import org.apache.sshd.common.keyprovider.AbstractClassLoadableResourceKeyPairProvider;
-import org.apache.sshd.common.util.SecurityUtils;
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.SshServer;
-import org.apache.sshd.server.auth.password.PasswordAuthenticator;
-import org.apache.sshd.server.command.ScpCommandFactory;
-import org.apache.sshd.server.session.ServerSession;
-import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import software.sham.ssh.MockSshServer;
-
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.Arrays;
+import java.util.Collections;
+
+import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
+import org.apache.sshd.sftp.server.SftpSubsystemFactory;
+
+import software.sham.ssh.MockSshServer;
public class MockSftpServer extends MockSshServer {
- private final Logger logger = LoggerFactory.getLogger(this.getClass());
- private Path baseDirectory;
+ private static final String MOCK_SFTP_DIRECTORY = "mock_sftp_root";
- public MockSftpServer(int port) throws IOException {
- this(port, false);
- }
+ private final Path baseDirectory;
- private MockSftpServer(int port, boolean enableShell) throws IOException {
- super(port, false);
- initSftp();
- if (enableShell) {
- enableShell();
- }
- start();
- }
+ public MockSftpServer(int port) throws IOException {
+ super(port, false);
- private void initSftp() {
- sshServer.setCommandFactory(new ScpCommandFactory());
- sshServer.setSubsystemFactories(Arrays.>asList(new SftpSubsystemFactory()));
- }
+ SftpSubsystemFactory sftpSubsystemFactory = new SftpSubsystemFactory.Builder().build();
+ super.getSshServer().setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory));
- public Path getBaseDirectory() {
- return baseDirectory;
- }
+ this.baseDirectory = Files.createTempDirectory(MOCK_SFTP_DIRECTORY);
+ log.info("baseDirectory: " + baseDirectory.toAbsolutePath().toString());
+ super.getSshServer().setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath()));
+ }
- @Override
- public void start() throws IOException {
- baseDirectory = Files.createTempDirectory("sftproot");
- sshServer.setFileSystemFactory(new VirtualFileSystemFactory(baseDirectory.toAbsolutePath().toString()));
- super.start();
- }
+ @Override
+ public void start() throws IOException {
+ super.start();
+ }
- @Override
- public void stop() throws IOException {
- super.stop();
- FileUtils.deleteQuietly(baseDirectory.toFile());
- }
+ @Override
+ public void stop() throws IOException {
+ super.stop();
+ this.baseDirectory.toFile().delete();
+ }
- public static MockSftpServer createWithShell(int port) throws IOException {
- return new MockSftpServer(port, true);
- }
+ public Path getBaseDirectory() {
+ return baseDirectory;
+ }
}
diff --git a/src/main/java/software/sham/ssh/CommandParserSupport.java b/src/main/java/software/sham/ssh/CommandParserSupport.java
new file mode 100644
index 0000000..e7f1863
--- /dev/null
+++ b/src/main/java/software/sham/ssh/CommandParserSupport.java
@@ -0,0 +1,43 @@
+package software.sham.ssh;
+
+import java.util.ArrayList;
+
+import org.apache.sshd.common.util.threads.CloseableExecutorService;
+import org.apache.sshd.server.command.AbstractCommandSupport;
+
+abstract class CommandParserSupport extends AbstractCommandSupport {
+
+ private static final String COMMAND_SEPERATOR = ";";
+ private static final String COMMAND_QUOTES = "\"";
+
+ public CommandParserSupport(String command, CloseableExecutorService executorService) {
+ super(command, executorService);
+ }
+
+ String[] divideCommands(String line) {
+ String[] frags = line.split(CommandParserSupport.COMMAND_SEPERATOR);
+
+ ArrayList commands = new ArrayList();
+
+ int i = 0;
+ while (i < frags.length) {
+ String frag = frags[i];
+
+ while ((frag.length() - frag.replace(CommandParserSupport.COMMAND_QUOTES, "").length()) % 2 == 1) {
+ if (i + 1 < frags.length) {
+ frag += CommandParserSupport.COMMAND_SEPERATOR;
+ frag += frags[++i];
+ } else {
+ log.error("command division met unbalanced quote situation in: " + line);
+ return new String[] { line };
+ }
+ }
+
+ commands.add(frag);
+ i++;
+ }
+
+ return commands.toArray(new String[0]);
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/software/sham/ssh/MockSshServer.java b/src/main/java/software/sham/ssh/MockSshServer.java
index f7fc0c4..5c755fd 100644
--- a/src/main/java/software/sham/ssh/MockSshServer.java
+++ b/src/main/java/software/sham/ssh/MockSshServer.java
@@ -1,118 +1,127 @@
package software.sham.ssh;
-import org.apache.sshd.common.Factory;
-import org.apache.sshd.common.keyprovider.AbstractClassLoadableResourceKeyPairProvider;
-import org.apache.sshd.common.util.SecurityUtils;
-import org.apache.sshd.server.Command;
-import org.apache.sshd.server.CommandFactory;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.PublicKey;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.apache.sshd.common.util.security.SecurityUtils;
import org.apache.sshd.server.SshServer;
import org.apache.sshd.server.auth.password.PasswordAuthenticator;
import org.apache.sshd.server.auth.pubkey.KeySetPublickeyAuthenticator;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.command.AbstractCommandSupport;
+import org.apache.sshd.server.command.Command;
+import org.apache.sshd.server.command.CommandFactory;
+import org.apache.sshd.server.keyprovider.AbstractGeneratorHostKeyProvider;
import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.shell.ShellFactory;
import org.hamcrest.Matcher;
-import org.hamcrest.Matchers;
+import org.hamcrest.core.IsInstanceOf;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.io.IOException;
-import java.security.GeneralSecurityException;
-import java.security.KeyFactory;
-import java.security.NoSuchAlgorithmException;
-import java.security.PublicKey;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.KeySpec;
-import java.security.spec.X509EncodedKeySpec;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Set;
-
-public class MockSshServer implements Factory, CommandFactory {
- public static final String USERNAME = "tester";
- public static final String PASSWORD = "testing";
- protected final SshServer sshServer;
- private Set keys = new HashSet();
-
- private final Logger logger = LoggerFactory.getLogger(this.getClass());
- private MockSshShell sshShell;
-
- public MockSshServer(int port) throws IOException {
- this(port, true);
- }
-
- protected MockSshServer(int port, boolean shouldStartServices) throws IOException {
- sshServer = initSshServer(port);
- if (shouldStartServices) {
- enableShell();
- start();
- }
- }
-
- /**
- * @param key Key in DER format
- */
- public MockSshServer allowPublicKey(byte[] key) throws GeneralSecurityException {
- final KeySpec spec = new X509EncodedKeySpec(key);
- keys.add(KeyFactory.getInstance("RSA").generatePublic(spec));
- sshServer.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this.keys));
- return this;
- }
-
- public SshResponderBuilder respondTo(Matcher matcher) {
- SshResponderBuilder builder = new SshResponderBuilder();
- sshShell.getDispatcher().add(matcher, builder.getResponder());
- return builder;
- }
-
- public SshResponderBuilder respondTo(String input) {
- return respondTo(Matchers.equalTo(input));
- }
-
- public MockSshServer enableShell() {
- logger.info("Mock SSH shell is enabled");
- sshShell = new MockSshShell();
- setDefaults();
- sshServer.setShellFactory(this);
- return this;
- }
-
- public void start() throws IOException {
- AbstractClassLoadableResourceKeyPairProvider keyPairProvider = SecurityUtils.createClassLoadableResourceKeyPairProvider();
- keyPairProvider.setResources(Arrays.asList("keys/sham-ssh-id-dsa"));
- sshServer.setKeyPairProvider(keyPairProvider);
-
- sshServer.start();
- }
-
- public void stop() throws IOException {
- sshServer.stop();
- }
-
- protected SshServer initSshServer(int port) {
- final SshServer sshd = SshServer.setUpDefaultServer();
- sshd.setPort(port);
- sshd.setPasswordAuthenticator(new PasswordAuthenticator() {
- @Override
- public boolean authenticate(String username, String password, ServerSession session) {
- return USERNAME.equals(username) && PASSWORD.equals(password);
- }
-
- });
- sshd.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this.keys));
- return sshd;
- }
-
- private void setDefaults() {
- respondTo("exit").withClose();
- }
-
- @Override
- public Command create() {
- logger.debug("Creating mock SSH shell");
- return sshShell;
- }
-
- @Override
- public Command createCommand(String command) {
- return create();
- }
+public class MockSshServer implements ShellFactory, CommandFactory {
+ public static final String USERNAME = "tester";
+ public static final String PASSWORD = "testing";
+
+ protected final Logger log = LoggerFactory.getLogger(this.getClass());
+
+ private final Set keys = new HashSet();
+ private final ResponderDispatcher dispatcher = new ResponderDispatcher();
+ private final SshServer sshServer;
+ private MockSshShell sshShell;
+
+ public MockSshServer(int port) throws IOException {
+ this(port, true);
+ }
+
+ public MockSshServer(int port, boolean shouldStartServices) throws IOException {
+ this.sshServer = SshServer.setUpDefaultServer();
+ this.sshServer.setPort(port);
+
+ this.sshServer.setPasswordAuthenticator(new PasswordAuthenticator() {
+ @Override
+ public boolean authenticate(String username, String password, ServerSession session) {
+ return USERNAME.equals(username) && PASSWORD.equals(password);
+ }
+
+ });
+ this.sshServer.setPublickeyAuthenticator(new KeySetPublickeyAuthenticator(this, this.keys));
+
+ this.sshServer.setCommandFactory(this);
+
+ if (shouldStartServices) {
+ enableShell();
+ start();
+ }
+ }
+
+ public MockSshServer allowPublicKey(PublicKey publicKey) {
+ this.keys.add(publicKey);
+ return this;
+ }
+
+ /**
+ * enables shell which basically reacts to `exit` and `echo`.
+ *
+ * @return fluent-api
+ */
+ public MockSshServer enableShell() {
+ log.info("Mock SSH shell is enabled");
+ sshShell = new MockSshShell(this.dispatcher);
+ sshShell.setDefaults();
+ sshServer.setShellFactory(this);
+ return this;
+ }
+
+ public void start() throws IOException {
+ Path serverKeyPath = Files.createTempFile("sham-mock-sshd-key", null);
+ AbstractGeneratorHostKeyProvider keyProvider = SecurityUtils.createGeneratorHostKeyProvider(serverKeyPath);
+ sshServer.setKeyPairProvider(keyProvider);
+
+ sshServer.start();
+ }
+
+ public void stop() throws IOException {
+ sshServer.stop();
+ }
+
+ public SshResponderBuilder respondTo(Matcher matcher) {
+ if (IsInstanceOf.class.equals(matcher.getClass()))
+ log.warn("Be careful with 'any' matcher, it may harm basic functions.");
+ return this.dispatcher.respondTo(matcher);
+ }
+
+ public SshResponderBuilder respondTo(String input) {
+ return this.dispatcher.respondTo(input);
+ }
+
+ @Override
+ public Command createShell(ChannelSession channel) throws IOException {
+ return this.sshShell; // mocked singleton
+ }
+
+ @Override
+ public Command createCommand(final ChannelSession channel, String command) throws IOException {
+ return new AbstractCommandSupport(command, null) {
+
+ @Override
+ public void run() {
+ try {
+ dispatcher.find(command).respond(getServerSession(), command, getOutputStream());
+ Thread.sleep(100L); // graceful wait for client read
+ onExit(0);
+ } catch (Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+ };
+ }
+
+ protected SshServer getSshServer() {
+ return sshServer;
+ }
}
diff --git a/src/main/java/software/sham/ssh/MockSshShell.java b/src/main/java/software/sham/ssh/MockSshShell.java
index 4b91783..a128ee8 100644
--- a/src/main/java/software/sham/ssh/MockSshShell.java
+++ b/src/main/java/software/sham/ssh/MockSshShell.java
@@ -1,147 +1,73 @@
package software.sham.ssh;
-import org.apache.commons.io.IOUtils;
-import org.apache.sshd.server.Command;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+
import org.apache.sshd.server.Environment;
-import org.apache.sshd.server.ExitCallback;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.*;
-import java.nio.ByteBuffer;
-import java.nio.CharBuffer;
-import java.nio.channels.Channels;
-import java.nio.charset.Charset;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
-
-public class MockSshShell implements Command {
- private final Logger logger = LoggerFactory.getLogger(getClass());
- private InputStream in;
- private OutputStream out;
- private OutputStream err;
- private ExitCallback callback;
- private final ResponderDispatcher dispatcher = new ResponderDispatcher();
- private final ExecutorService executor = Executors.newSingleThreadExecutor();
- private MockShellEventLoop eventLoop = new MockShellEventLoop(this);
- private Future eventLoopFuture;
-
- @Override
- public void setInputStream(InputStream in) {
- this.in = in;
- }
-
- @Override
- public void setOutputStream(OutputStream out) {
- this.out = out;
- }
-
- @Override
- public void setErrorStream(OutputStream err) {
- this.err = err;
- }
-
- @Override
- public void setExitCallback(ExitCallback callback) {
- this.callback = callback;
- }
-
- @Override
- public void start(Environment env) throws IOException {
- logger.debug("Starting mock SSH shell");
- eventLoopFuture = (Future) executor.submit(eventLoop);
- }
-
- @Override
- public void destroy() {
- closeSession();
- executor.shutdown();
- }
-
- public void closeSession() {
- eventLoop.stop();
- }
-
- protected List readInput() throws IOException {
- StringBuffer sb = new StringBuffer();
- final Charset charset = StandardCharsets.UTF_8;
- ByteBuffer buffer = ByteBuffer.allocate(1024);
- int len = in.available();
- while (len > 0) {
- if (len > 1024) len = 1024;
- int lenRead = in.read(buffer.array(), 0, len);
- CharBuffer cb = charset.decode(buffer);
- sb.append(cb, 0, lenRead);
- logger.trace("Read {} characters from {} bytes", cb.length(), lenRead);
- len = in.available();
- }
- return Arrays.asList(sb.toString().split("\\r?\\n"));
- }
-
- protected void writeError(Exception e) throws IOException {
- Writer writer = Channels.newWriter(Channels.newChannel(err), StandardCharsets.UTF_8.name());
- writer.write(e.toString());
- writer.flush();
- writer.close();
- }
-
- public void sendResponse(String output) {
- try {
- out.write(output.getBytes());
- logger.trace("Wrote output {}", output);
- out.flush();
- } catch (IOException e) {
- logger.error("Error sending response to client", e);
- }
- }
-
- public ResponderDispatcher getDispatcher() {
- return this.dispatcher;
- }
-
- public class MockShellEventLoop implements Runnable {
- private boolean stopped = false;
- private final MockSshShell shell;
- public MockShellEventLoop(MockSshShell shell) {
- this.shell = shell;
- }
-
- public void stop() {
- if (!this.stopped) {
- this.stopped = true;
- logger.info("Stopped Mock SSH shell event loop");
- }
- }
-
- @Override
- public void run() {
- while(! stopped) {
- logger.trace("Polling input...");
- try {
- List input = shell.readInput();
- logger.trace("Returned from reading input");
- for (String line : input) {
- logger.debug("SSH server received input [{}]", line.toString());
- dispatcher.find(line).respond(shell);
- }
- Thread.sleep(100);
- } catch (IOException e) {
- try {
- shell.writeError(e);
- } catch (IOException e2) {
- System.err.println(e2.toString());
- }
- } catch (InterruptedException e) {
- logger.debug("Interrupted event loop thread: " + e.getMessage());
- }
- }
- logger.debug("Event loop completed");
- callback.onExit(0);
- }
- }
+import org.apache.sshd.server.channel.ChannelSession;
+import org.hamcrest.Matchers;
+
+import software.sham.ssh.actions.Action;
+import software.sham.ssh.actions.Greet;
+import software.sham.ssh.actions.Prompt;
+
+class MockSshShell extends CommandParserSupport {
+
+ private final ResponderDispatcher dispatcher;
+
+ private final Action greet = new Greet();
+ private final Action prompt = new Prompt();
+
+ protected MockSshShell(ResponderDispatcher dispatcher) {
+ super("shell", null);
+
+ this.dispatcher = dispatcher;
+ }
+
+ @Override
+ public void start(ChannelSession channelSession, Environment env) throws IOException {
+ greet.respond(getServerSession(), getOutputStream());
+
+ executorService.submit(() -> {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(getInputStream()))) {
+ while (super.getServerSession().isOpen()) {
+ String[] commands = super.divideCommands(reader.readLine());
+ for (String command : commands) {
+ SshResponder responder = dispatcher.find(command.trim());
+ responder.respond(getServerSession(), command, getOutputStream());
+ }
+
+ prompt.respond(getServerSession(), getOutputStream());
+
+ getOutputStream().flush();
+ }
+ } catch (IOException ex) {
+ log.error("Shell aborting due to Exception.", ex);
+ }
+ });
+ }
+
+ @Override
+ public void destroy(ChannelSession channelSession) throws Exception {
+ executorService.shutdown();
+ }
+
+ @Override
+ public void run() {
+ // do nothing here
+ }
+
+ /**
+ * Activate mocked behavior for
+ *
+ * - basic `echo` (including mocked $?)
+ * - `exit` closing connection from server-end
+ *
+ */
+ public void setDefaults() {
+ this.dispatcher.respondTo(Matchers.startsWith("echo")).withEcho();
+
+ this.dispatcher.respondTo("exit").withClose();
+ }
}
diff --git a/src/main/java/software/sham/ssh/ResponderDispatcher.java b/src/main/java/software/sham/ssh/ResponderDispatcher.java
index 1a61a23..64d5911 100644
--- a/src/main/java/software/sham/ssh/ResponderDispatcher.java
+++ b/src/main/java/software/sham/ssh/ResponderDispatcher.java
@@ -1,6 +1,7 @@
package software.sham.ssh;
import org.hamcrest.Matcher;
+import org.hamcrest.Matchers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -8,27 +9,48 @@
import java.util.LinkedList;
import java.util.Map;
-public class ResponderDispatcher {
- private final Logger logger = LoggerFactory.getLogger(getClass());
- private final Map responders = new HashMap<>();
- private final LinkedList matchers = new LinkedList<>();
-
- public void add(Matcher matcher, SshResponder responder) {
- matchers.add(0, matcher); // matchers added last take precedence
- responders.put(matcher, responder);
- }
-
- public SshResponder find(String input) {
- for (Matcher matcher : matchers) {
- logger.debug("checking {}", matcher.toString());
- if (matcher.matches(input)) {
- logger.debug("Found responder for " + matcher.toString());
- return responders.get(matcher);
- } else {
- logger.debug("did not match " +matcher.toString());
- }
- }
- logger.info("No responder found for input " + input);
- return SshResponder.NULL;
- }
+/**
+ * dispatcher.matchers (reverse-order) -> responder
+ *
+ * TODO pass-through input
+ */
+class ResponderDispatcher {
+ private final Logger logger = LoggerFactory.getLogger(getClass());
+
+ private final Map, SshResponder> responders = new HashMap<>();
+ private final LinkedList> matchers = new LinkedList<>();
+
+ public SshResponderBuilder respondTo(Matcher matcher) {
+ SshResponderBuilder builder = SshResponderBuilder.builder();
+ this.add(matcher, builder.getResponder());
+ return builder;
+ }
+
+ public SshResponderBuilder respondTo(String input) {
+ return this.respondTo(Matchers.equalTo(input));
+ }
+
+ public SshResponder find(String input) {
+ for (Matcher matcher : matchers) {
+ logger.debug("checking {}", matcher.toString());
+ if (matcher.matches(input)) {
+ logger.debug("Found responder for " + matcher.toString());
+ return responders.get(matcher);
+ } else {
+ logger.debug("did not match " + matcher.toString());
+ }
+ }
+ logger.info("No responder found for input " + input);
+ return SshResponder.NULL;
+ }
+
+ private void add(Matcher matcher, SshResponder responder) {
+ matchers.add(0, matcher); // matchers added last take precedence
+ responders.put(matcher, responder);
+ }
+
+ public void clear() {
+ matchers.clear();
+ responders.clear();
+ }
}
diff --git a/src/main/java/software/sham/ssh/SshResponder.java b/src/main/java/software/sham/ssh/SshResponder.java
index 84d7bac..170b45e 100644
--- a/src/main/java/software/sham/ssh/SshResponder.java
+++ b/src/main/java/software/sham/ssh/SshResponder.java
@@ -1,34 +1,36 @@
package software.sham.ssh;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import software.sham.ssh.actions.Action;
-
import java.io.IOException;
import java.io.OutputStream;
-import java.nio.channels.Channels;
-import java.nio.channels.WritableByteChannel;
import java.util.LinkedList;
import java.util.List;
-public class SshResponder {
- private final Logger logger = LoggerFactory.getLogger(getClass());
+import org.apache.sshd.server.session.ServerSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import software.sham.ssh.actions.Action;
+
+class SshResponder {
+ public static final SshResponder NULL = new SshResponder();
+
+ private final Logger logger = LoggerFactory.getLogger(getClass());
- private final List actions = new LinkedList<>();
+ private final List actions = new LinkedList<>();
- public void respond(MockSshShell shell) {
- for (Action action : actions) {
- try {
- action.respond(shell);
- } catch (IOException e) {
- logger.warn("Mock SSH error during response {}: {}", action.toString(), e.getMessage());
- }
- }
- }
+ public void respond(ServerSession serverSession, String input, OutputStream outputStream) {
+ for (Action action : actions) {
+ try {
+ action.respond(serverSession, input, outputStream);
+ } catch (IOException e) {
+ logger.warn("Mock SSH error during response {}: {}", action.toString(), e.getMessage());
+ }
+ }
+ }
- public void add(Action action) {
- actions.add(action);
- }
+ public SshResponder add(Action action) {
+ actions.add(action);
+ return this;
+ }
- public static SshResponder NULL = new SshResponder();
}
diff --git a/src/main/java/software/sham/ssh/SshResponderBuilder.java b/src/main/java/software/sham/ssh/SshResponderBuilder.java
index 7817891..71a0dec 100644
--- a/src/main/java/software/sham/ssh/SshResponderBuilder.java
+++ b/src/main/java/software/sham/ssh/SshResponderBuilder.java
@@ -2,27 +2,40 @@
import software.sham.ssh.actions.Close;
import software.sham.ssh.actions.Delay;
+import software.sham.ssh.actions.Echo;
import software.sham.ssh.actions.Output;
+/**
+ *
+ */
public class SshResponderBuilder {
- private final SshResponder responder = new SshResponder();
-
- public SshResponder getResponder() {
- return this.responder;
- }
-
- public SshResponderBuilder withOutput(String output) {
- responder.add(new Output(output));
- return this;
- }
-
- public SshResponderBuilder withDelay(long milliseconds) {
- responder.add(new Delay(milliseconds));
- return this;
- }
-
- public SshResponderBuilder withClose() {
- responder.add(new Close());
- return this;
- }
+ private final SshResponder responder = new SshResponder();
+
+ public static SshResponderBuilder builder() {
+ return new SshResponderBuilder();
+ }
+
+ public SshResponder getResponder() {
+ return this.responder;
+ }
+
+ public SshResponderBuilder withOutput(String... multiLineOutput) {
+ responder.add(new Output(multiLineOutput));
+ return this;
+ }
+
+ public SshResponderBuilder withDelay(long milliseconds) {
+ responder.add(new Delay(milliseconds));
+ return this;
+ }
+
+ public SshResponderBuilder withClose() {
+ responder.add(new Close());
+ return this;
+ }
+
+ public SshResponderBuilder withEcho() {
+ responder.add(new Echo());
+ return this;
+ }
}
diff --git a/src/main/java/software/sham/ssh/actions/Action.java b/src/main/java/software/sham/ssh/actions/Action.java
index 9e8cb40..40730b9 100644
--- a/src/main/java/software/sham/ssh/actions/Action.java
+++ b/src/main/java/software/sham/ssh/actions/Action.java
@@ -1,9 +1,27 @@
package software.sham.ssh.actions;
-import software.sham.ssh.MockSshShell;
-
import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.sshd.server.session.ServerSession;
+/**
+ * callers must use {@link #respond(ServerSession, String, OutputStream)}
+ */
public interface Action {
- void respond(MockSshShell shell) throws IOException;
+ default void respond() {
+ throw new RuntimeException("not implemented");
+ }
+
+ default void respond(ServerSession serverSession) throws IOException {
+ respond();
+ }
+
+ default void respond(ServerSession serverSession, OutputStream outputStream) throws IOException {
+ respond(serverSession);
+ };
+
+ default void respond(ServerSession serverSession, String input, OutputStream outputStream) throws IOException {
+ respond(serverSession, outputStream);
+ };
}
diff --git a/src/main/java/software/sham/ssh/actions/Close.java b/src/main/java/software/sham/ssh/actions/Close.java
index c3cb02e..7935508 100644
--- a/src/main/java/software/sham/ssh/actions/Close.java
+++ b/src/main/java/software/sham/ssh/actions/Close.java
@@ -1,10 +1,13 @@
package software.sham.ssh.actions;
-import software.sham.ssh.MockSshShell;
+import java.io.IOException;
+
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.server.session.ServerSession;
public class Close implements Action {
- @Override
- public void respond(MockSshShell shell) {
- shell.closeSession();
- }
+ @Override
+ public void respond(ServerSession serverSession) throws IOException {
+ serverSession.disconnect(SshConstants.SSH2_DISCONNECT_BY_APPLICATION, "mock close");
+ }
}
diff --git a/src/main/java/software/sham/ssh/actions/Delay.java b/src/main/java/software/sham/ssh/actions/Delay.java
index db9abf7..bee7605 100644
--- a/src/main/java/software/sham/ssh/actions/Delay.java
+++ b/src/main/java/software/sham/ssh/actions/Delay.java
@@ -2,7 +2,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import software.sham.ssh.MockSshShell;
public class Delay implements Action {
private final Logger logger = LoggerFactory.getLogger(getClass());
@@ -13,7 +12,7 @@ public Delay(long milliseconds) {
}
@Override
- public void respond(MockSshShell shell) {
+ public void respond() {
try {
logger.debug("Delaying output for {}ms", milliseconds);
Thread.sleep(milliseconds);
@@ -21,9 +20,4 @@ public void respond(MockSshShell shell) {
logger.warn("Interrupted before delay completed: {}", e.getMessage());
}
}
-
- @Override
- public String toString() {
- return "delay (" + milliseconds + "ms)";
- }
}
diff --git a/src/main/java/software/sham/ssh/actions/Echo.java b/src/main/java/software/sham/ssh/actions/Echo.java
new file mode 100644
index 0000000..b9002f9
--- /dev/null
+++ b/src/main/java/software/sham/ssh/actions/Echo.java
@@ -0,0 +1,34 @@
+package software.sham.ssh.actions;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.sshd.server.session.ServerSession;
+
+public class Echo extends OutputSupport implements Action {
+
+ private static final Pattern ECHO_PATTERN = Pattern.compile("[ ]*echo[ ]+\"?([^\"]+)\"?");
+ private final int returnCode;
+
+ public Echo() {
+ this.returnCode = 0;
+ }
+
+ public Echo(int returnCode) {
+ this.returnCode = returnCode;
+ }
+
+ @Override
+ public void respond(ServerSession serverSession, String input, OutputStream outputStream) throws IOException {
+ Matcher matcher = ECHO_PATTERN.matcher(input);
+
+ while (matcher.find()) {
+ String output = matcher.group(1) //
+ .replace("$?", String.valueOf(returnCode));
+ super.write(output, outputStream);
+ }
+ }
+
+}
diff --git a/src/main/java/software/sham/ssh/actions/Greet.java b/src/main/java/software/sham/ssh/actions/Greet.java
new file mode 100644
index 0000000..be701c1
--- /dev/null
+++ b/src/main/java/software/sham/ssh/actions/Greet.java
@@ -0,0 +1,29 @@
+package software.sham.ssh.actions;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.apache.sshd.server.session.ServerSession;
+
+public class Greet extends OutputSupport implements Action {
+
+ private final String greeting;
+
+ public Greet() {
+ // e.g. Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-51-generic x86_64)
+ // TODO bring maven-artifact-version via properties file into this spot
+ this.greeting = String.format("Welcome to %s (GNU/Linux %s x86_64)\n", //
+ this.getClass().getSimpleName(), //
+ "1.2.3" //
+ );
+ }
+
+ public Greet(String greeting) {
+ this.greeting = greeting;
+ }
+
+ @Override
+ public void respond(ServerSession serverSession, OutputStream outputStream) throws IOException {
+ super.write(greeting, outputStream);
+ }
+}
diff --git a/src/main/java/software/sham/ssh/actions/Output.java b/src/main/java/software/sham/ssh/actions/Output.java
index c5b51b6..af02e8b 100644
--- a/src/main/java/software/sham/ssh/actions/Output.java
+++ b/src/main/java/software/sham/ssh/actions/Output.java
@@ -1,24 +1,25 @@
package software.sham.ssh.actions;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import software.sham.ssh.MockSshShell;
+import java.io.IOException;
+import java.io.OutputStream;
-public class Output implements Action {
- private final Logger logger = LoggerFactory.getLogger(getClass());
- private final String output;
- public Output(String output) {
- this.output = output;
- }
+import org.apache.sshd.server.session.ServerSession;
+
+public class Output extends OutputSupport implements Action {
+
+ private final String[] outputs;
+
+ public Output(String... outputs) {
+ super();
+
+ this.outputs = outputs;
+ }
+
+ @Override
+ public void respond(ServerSession serverSession, OutputStream outputStream) throws IOException {
+ for (String output: outputs)
+ super.write(output, outputStream);
+ }
- @Override
- public void respond(MockSshShell shell) {
- logger.debug("Sending output: " + output);
- shell.sendResponse(output);
- }
- @Override
- public String toString() {
- return "output (" + output + ")";
- }
}
diff --git a/src/main/java/software/sham/ssh/actions/OutputSupport.java b/src/main/java/software/sham/ssh/actions/OutputSupport.java
new file mode 100644
index 0000000..f610a04
--- /dev/null
+++ b/src/main/java/software/sham/ssh/actions/OutputSupport.java
@@ -0,0 +1,18 @@
+package software.sham.ssh.actions;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+abstract class OutputSupport {
+ protected static final byte[] EOL = System.lineSeparator().getBytes();
+ protected final Logger logger = LoggerFactory.getLogger(getClass());
+
+ void write(String output, OutputStream outputStream) throws IOException {
+ logger.debug("Sending output: " + output);
+ outputStream.write(output.getBytes());
+ outputStream.write(EOL);
+ }
+}
diff --git a/src/main/java/software/sham/ssh/actions/Prompt.java b/src/main/java/software/sham/ssh/actions/Prompt.java
new file mode 100644
index 0000000..03878f2
--- /dev/null
+++ b/src/main/java/software/sham/ssh/actions/Prompt.java
@@ -0,0 +1,33 @@
+package software.sham.ssh.actions;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.text.MessageFormat;
+
+import org.apache.sshd.server.session.ServerSession;
+
+public class Prompt extends OutputSupport implements Action {
+
+ private final String promptFormat;
+
+ /**
+ * "{0}" <- {@link ServerSession#getUsername()}
+ *
+ * @param prompt
+ * @see MessageFormat
+ */
+ public Prompt(String prompt) {
+ this.promptFormat = prompt;
+ }
+
+ public Prompt() {
+ this.promptFormat = "{0}@shell-$ ";
+ }
+
+ @Override
+ public void respond(ServerSession serverSession, OutputStream outputStream) throws IOException {
+ String prompt = MessageFormat.format(promptFormat, serverSession.getUsername());
+
+ super.write(prompt, outputStream);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/software/sham/MockSshServerTestSupport.java b/src/test/java/software/sham/MockSshServerTestSupport.java
new file mode 100644
index 0000000..b81fa7a
--- /dev/null
+++ b/src/test/java/software/sham/MockSshServerTestSupport.java
@@ -0,0 +1,88 @@
+package software.sham;
+
+import java.io.IOException;
+
+import org.junit.After;
+import org.junit.Before;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.sshtools.client.SshClient;
+import com.sshtools.client.SshClient.SshClientBuilder;
+import com.sshtools.common.ssh.SshException;
+
+import software.sham.ssh.MockSshServer;
+
+public abstract class MockSshServerTestSupport {
+
+ protected static final String SSH_SERVER_HOSTNAME = "localhost";
+ protected static final int SSH_SERVER_PORT = 9022;
+ protected static final String SSH_SERVER_USER = MockSshServer.USERNAME;
+ protected static final String SSH_SERVER_PASSWORD = MockSshServer.PASSWORD;
+ protected static final String SSH_SERVER_USER_KEY_PASSPHRASE = "testing";
+ protected static final long SSH_SERVER_CONNECT_TIMOUT = 1000L;
+
+ protected final Logger logger = LoggerFactory.getLogger(getClass());
+
+ protected MockSshServer server;
+ protected SshClient sshClient;
+
+ protected interface MockSshServerConfigurer {
+ void apply(MockSshServer server);
+ }
+
+ protected interface MockSshServerCreator {
+ MockSshServer create() throws IOException;
+ }
+
+ MockSshServerConfigurer serverConfigurer = server -> {
+ /* do nothing */ };
+ private MockSshServerCreator serverCreator = () -> {
+ return new MockSshServer(MockSshServerTestSupport.SSH_SERVER_PORT, false);
+ };
+
+ protected MockSshServerTestSupport(MockSshServerCreator serverCreator, MockSshServerConfigurer serverConfigurer) {
+ this.serverCreator = serverCreator;
+ this.serverConfigurer = serverConfigurer;
+ }
+
+ protected MockSshServerTestSupport(MockSshServerCreator serverCreator) {
+ this.serverCreator = serverCreator;
+ }
+
+ protected MockSshServerTestSupport(MockSshServerConfigurer serverConfigurer) {
+ this.serverConfigurer = serverConfigurer;
+ }
+
+ protected MockSshServerTestSupport() {
+ // no specific serverConfigurer
+ }
+
+ @Before
+ public void startSshServer() throws IOException {
+ this.server = this.serverCreator.create();
+ this.serverConfigurer.apply(server);
+ this.server.start();
+ }
+
+ @After
+ public void stopSsh() throws IOException, InterruptedException {
+ if (sshClient != null)
+ this.sshClient.close();
+ this.server.stop();
+ }
+
+ protected void initSshClientWithPassword() throws SshException, IOException {
+ this.sshClient = SshClientBuilder.create() //
+ .withTarget(MockSshServerTestSupport.SSH_SERVER_HOSTNAME, MockSshServerTestSupport.SSH_SERVER_PORT) //
+ .withUsername(MockSshServerTestSupport.SSH_SERVER_USER) //
+ .withPassword(MockSshServerTestSupport.SSH_SERVER_PASSWORD) //
+ .build();
+
+ this.sshClient.openSessionChannel(MockSshServerTestSupport.SSH_SERVER_CONNECT_TIMOUT);
+ }
+
+ protected String sendTextToServer(final String text) throws IOException {
+ return this.sshClient.executeCommand(text);
+ }
+}
diff --git a/src/test/java/software/sham/git/MockGitServerTest.java b/src/test/java/software/sham/git/MockGitServerTest.java
new file mode 100644
index 0000000..1b7f6e6
--- /dev/null
+++ b/src/test/java/software/sham/git/MockGitServerTest.java
@@ -0,0 +1,93 @@
+package software.sham.git;
+
+import static org.junit.Assert.assertTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.security.GeneralSecurityException;
+import java.util.Collections;
+
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.auth.password.PasswordIdentityProvider;
+import org.apache.sshd.client.future.AuthFuture;
+import org.apache.sshd.client.future.ConnectFuture;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.session.SessionContext;
+import org.apache.sshd.git.transport.GitSshdSessionFactory;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.api.errors.InvalidRemoteException;
+import org.eclipse.jgit.api.errors.TransportException;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.junit.Before;
+import org.junit.Test;
+
+import software.sham.MockSshServerTestSupport;
+
+public class MockGitServerTest extends MockSshServerTestSupport {
+
+ private static final String GIT_PROJECT = "mocked-repo";
+ private static final String GIT_SSH_URI = String.format("ssh://%s@%s:%d/%s", //
+ SSH_SERVER_USER, SSH_SERVER_HOSTNAME, SSH_SERVER_PORT, GIT_PROJECT);
+
+ private static final UsernamePasswordCredentialsProvider CREDENTIALS_PROVIDER = new UsernamePasswordCredentialsProvider(
+ SSH_SERVER_USER, SSH_SERVER_PASSWORD);
+
+ private SshClient client;
+
+ public MockGitServerTest() {
+ super(() -> new MockGitServer(MockSshServerTestSupport.SSH_SERVER_PORT));
+ }
+
+ @Before
+ public void createSshClient() {
+ client = SshClient.setUpDefaultClient();
+ client.setPasswordIdentityProvider(new PasswordIdentityProvider() {
+ @Override
+ public Iterable loadPasswords(SessionContext session) throws IOException, GeneralSecurityException {
+ return Collections.singleton(SSH_SERVER_PASSWORD);
+ }
+ });
+ client.start();
+
+ SshSessionFactory gitSessionFactory = new GitSshdSessionFactory(client);
+ SshSessionFactory.setInstance(gitSessionFactory);
+ }
+
+ @Test
+ public void canConnect() throws IOException {
+ ConnectFuture connectFuture = client.connect(GIT_SSH_URI);
+ assertTrue(connectFuture.await());
+ assertTrue(connectFuture.isConnected());
+
+ ClientSession session = connectFuture.getSession();
+ AuthFuture authFuture = session.auth();
+ assertTrue(authFuture.await());
+ assertTrue(authFuture.isSuccess());
+ }
+
+ @Test
+ public void connectAndFetch()
+ throws InvalidRemoteException, TransportException, GitAPIException, IOException, InterruptedException {
+ File directory = Files.createTempDirectory(getClass().getSimpleName()).toFile();
+ directory.deleteOnExit();
+
+ getServer().prepareGitProject(GIT_PROJECT);
+
+ Git git = Git.cloneRepository().setURI(GIT_SSH_URI).setDirectory(directory)
+ // surprisingly JGIT wouldn't take sshClient setup
+ .setCredentialsProvider(CREDENTIALS_PROVIDER).call();
+
+ git.fetch().setCredentialsProvider(CREDENTIALS_PROVIDER).call();
+
+ Iterable commits = git.log().call();
+ assertTrue(commits.iterator().hasNext());
+ }
+
+ public MockGitServer getServer() {
+ return (MockGitServer) super.server;
+ }
+}
diff --git a/src/test/java/software/sham/sftp/FunctionalTest.java b/src/test/java/software/sham/sftp/FunctionalTest.java
deleted file mode 100644
index 0137919..0000000
--- a/src/test/java/software/sham/sftp/FunctionalTest.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package software.sham.sftp;
-
-import static org.junit.Assert.*;
-
-import com.jcraft.jsch.*;
-import org.apache.commons.io.IOUtils;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-import java.io.IOException;
-import java.nio.file.Files;
-import java.util.Properties;
-
-public class FunctionalTest {
- MockSftpServer server;
- Session sshSession;
-
- @Before
- public void initSftp() throws IOException {
- server = new MockSftpServer(9022);
- }
-
- @Before
- public void initSshClient() throws JSchException {
- JSch jsch = new JSch();
- sshSession = jsch.getSession("tester", "localhost", 9022);
- Properties config = new Properties();
- config.setProperty("StrictHostKeyChecking", "no");
- sshSession.setConfig(config);
- sshSession.setPassword("testing");
- sshSession.connect();
- }
-
- @After
- public void stopSftp() throws IOException {
- server.stop();
- }
-
- @Test
- public void connectAndDownloadFile() throws JSchException, IOException, SftpException {
- Files.copy(IOUtils.toInputStream("example file contents"), server.getBaseDirectory().resolve("example.txt"));
-
- ChannelSftp channel = (ChannelSftp) sshSession.openChannel("sftp");
- channel.connect();
-
- final String downloadedContents = IOUtils.toString(channel.get("example.txt"));
- assertEquals("example file contents", downloadedContents);
- }
-}
diff --git a/src/test/java/software/sham/sftp/MockSftpServerTest.java b/src/test/java/software/sham/sftp/MockSftpServerTest.java
new file mode 100644
index 0000000..6b7c968
--- /dev/null
+++ b/src/test/java/software/sham/sftp/MockSftpServerTest.java
@@ -0,0 +1,64 @@
+package software.sham.sftp;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import com.sshtools.client.sftp.SftpClient;
+import com.sshtools.client.sftp.SftpClient.SftpClientBuilder;
+import com.sshtools.client.sftp.TransferCancelledException;
+import com.sshtools.common.permissions.PermissionDeniedException;
+import com.sshtools.common.sftp.SftpStatusException;
+import com.sshtools.common.ssh.SshException;
+
+import software.sham.MockSshServerTestSupport;
+
+public class MockSftpServerTest extends MockSshServerTestSupport {
+
+ static final String TEST_FILE = "example.txt";
+ static final String TEST_FILE_CONTENT = "example file contents" + System.lineSeparator();
+
+ private SftpClient sftpClient;
+
+ public MockSftpServerTest() {
+ super( //
+ () -> new MockSftpServer(MockSshServerTestSupport.SSH_SERVER_PORT), //
+ server -> server.enableShell() //
+ );
+ }
+
+ @Before
+ public void initSshClient() throws SshException, PermissionDeniedException, IOException {
+ super.initSshClientWithPassword();
+ this.sftpClient = SftpClientBuilder.create().withClient(this.sshClient).build();
+ }
+
+ @Test
+ public void connectAndDownloadFile()
+ throws IOException, SftpStatusException, SshException, TransferCancelledException {
+ Files.write( //
+ getSftpServer().getBaseDirectory().resolve(TEST_FILE), //
+ TEST_FILE_CONTENT.getBytes(Charset.defaultCharset()) //
+ );
+
+ assertTrue(sftpClient.exists(TEST_FILE));
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ sftpClient.get(TEST_FILE, baos);
+
+ assertEquals(TEST_FILE_CONTENT, baos.toString());
+
+ sftpClient.close();
+ }
+
+ protected MockSftpServer getSftpServer() {
+ return (MockSftpServer) super.server;
+ }
+}
diff --git a/src/test/java/software/sham/ssh/FunctionalTest.java b/src/test/java/software/sham/ssh/FunctionalTest.java
deleted file mode 100644
index 2501372..0000000
--- a/src/test/java/software/sham/ssh/FunctionalTest.java
+++ /dev/null
@@ -1,158 +0,0 @@
-package software.sham.ssh;
-
-import static org.junit.Assert.*;
-import static org.hamcrest.Matchers.*;
-
-import com.jcraft.jsch.ChannelShell;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-import com.jcraft.jsch.Session;
-import org.apache.commons.io.IOUtils;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.*;
-import java.util.Properties;
-
-public class FunctionalTest {
- private final Logger logger = LoggerFactory.getLogger(getClass());
- MockSshServer server;
- Session sshSession;
- ChannelShell sshChannel;
- ByteArrayOutputStream sshClientOutput;
- PrintWriter inputWriter;
-
- @Before
- public void initSsh() throws IOException {
- server = new MockSshServer(9022);
- }
-
- @Before
- public void initSshClientWithPassword() throws JSchException, IOException {
- initSshClient();
-
- sshSession.setPassword("testing");
-
- connectWithStreams();
- }
-
- private void initSshClientWithKey() throws JSchException, IOException {
- JSch jsch = initSshClient();
-
- jsch.addIdentity("src/test/resources/keys/id_rsa_tester", "testing");
-
- connectWithStreams();
- }
-
- private JSch initSshClient() throws JSchException {
- JSch jsch = new JSch();
- sshSession = jsch.getSession("tester", "localhost", 9022);
- Properties config = new Properties();
- config.setProperty("StrictHostKeyChecking", "no");
- sshSession.setConfig(config);
- return jsch;
- }
-
- private void connectWithStreams() throws JSchException, IOException {
- sshSession.connect();
- sshChannel = (ChannelShell) sshSession.openChannel("shell");
- PipedInputStream channelIn = new PipedInputStream();
- sshChannel.setInputStream(channelIn);
- OutputStream sshClientInput = new PipedOutputStream(channelIn);
- inputWriter = new PrintWriter(sshClientInput);
- sshClientOutput = new ByteArrayOutputStream();
- sshChannel.setOutputStream(sshClientOutput);
- sshChannel.connect(1000);
- }
-
- @After
- public void stopSsh() throws Exception {
- sshSession.disconnect();
- server.stop();
- Thread.sleep(200);
- }
-
- @Test
- public void defaultShellCommandsShouldSilentlySucceed() throws Exception {
- sendTextToServer("Knock knock\n");
- assertThat(sshClientOutput.size(), equalTo(0));
- }
-
- @Test
- public void defaultShellShouldDisconnectOnExit() throws Exception {
- sendTextToServer("exit\n");
- Thread.sleep(300);
- assertThat(sshChannel.isConnected(), is(false));
- }
-
- @Test
- public void singleOutput() throws Exception {
- server.respondTo(any(String.class))
- .withOutput("hodor\n");
-
- sendTextToServer("Knock knock\n");
- assertEquals("hodor\n", sshClientOutput.toString());
- }
-
- @Test
- public void shouldSupportPublicKeyAuth() throws Exception {
- server.allowPublicKey(IOUtils.toByteArray(Thread.currentThread().getContextClassLoader().getResourceAsStream("keys/id_rsa_tester.der.pub")));
-
- initSshClientWithKey();
-
- server.respondTo(any(String.class))
- .withOutput("hodor\n");
-
- sendTextToServer("Knock knock\n");
- assertEquals("hodor\n", sshClientOutput.toString());
- }
-
- @Test
- public void multipleOutput() throws Exception {
- server.respondTo(any(String.class))
- .withOutput("Starting...\n")
- .withOutput("Completed.\n");
-
- sendTextToServer("start");
- assertEquals("Starting...\nCompleted.\n", sshClientOutput.toString());
- }
-
- @Test
- public void delayedOutput() throws Exception {
- server.respondTo(any(String.class))
- .withOutput("Starting...\n")
- .withDelay(500)
- .withOutput("Completed.\n");
-
- sendTextToServer("start");
- logger.debug("Checking for first line");
- assertEquals("Starting...\n", sshClientOutput.toString());
- Thread.sleep(500);
- logger.debug("Checking for second line");
- assertEquals("Starting...\nCompleted.\n", sshClientOutput.toString());
- }
-
- @Test
- public void differentOutputForDifferentInput() throws Exception {
- server.respondTo(any(String.class))
- .withOutput("default\n");
- server.respondTo("Knock knock")
- .withOutput("Who's there?\n");
-
- sendTextToServer("Something wicked this way comes");
- String output = sshClientOutput.toString();
- assertEquals("default\n", output);
- sendTextToServer("Knock knock");
- assertEquals("default\nWho's there?\n", sshClientOutput.toString());
- }
-
- private void sendTextToServer(final String text) throws Exception {
- inputWriter.write(text);
- inputWriter.flush();
- logger.debug("Sent text to SSH server: {}", text);
- Thread.sleep(100);
- }
-}
diff --git a/src/test/java/software/sham/ssh/MockSshCommandTest.java b/src/test/java/software/sham/ssh/MockSshCommandTest.java
new file mode 100644
index 0000000..7f3437b
--- /dev/null
+++ b/src/test/java/software/sham/ssh/MockSshCommandTest.java
@@ -0,0 +1,49 @@
+package software.sham.ssh;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.any;
+import static org.hamcrest.Matchers.emptyString;
+import static org.junit.Assert.assertEquals;
+
+import java.io.IOException;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import com.sshtools.common.ssh.SshException;
+
+import software.sham.MockSshServerTestSupport;
+
+public class MockSshCommandTest extends MockSshServerTestSupport {
+
+ @Before
+ public void initSshClientWithPassword() throws SshException, IOException {
+ super.initSshClientWithPassword();
+ }
+
+ @Test
+ public void defaultShellCommandsShouldSilentlySucceed() throws IOException {
+ assertThat(sendTextToServer("Knock knock"), emptyString());
+ }
+
+ @Test
+ public void singleOutput() throws IOException {
+ server.respondTo(any(String.class)).withOutput("hodor");
+
+ assertEquals("hodor\n", sendTextToServer("Knock knock"));
+ }
+
+ @Test
+ public void multipleOutput() throws IOException {
+ server.respondTo(any(String.class)).withOutput("Starting...").withOutput("Completed.");
+
+ assertEquals("Starting...\nCompleted.\n", sendTextToServer("start"));
+ }
+
+ @Test
+ public void multilineOutput() throws IOException {
+ server.respondTo(any(String.class)).withOutput("Starting...", "Completed.");
+
+ assertEquals("Starting...\nCompleted.\n", sendTextToServer("start"));
+ }
+}
diff --git a/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java b/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java
new file mode 100644
index 0000000..bfad8cd
--- /dev/null
+++ b/src/test/java/software/sham/ssh/MockSshServerConnectionTest.java
@@ -0,0 +1,122 @@
+package software.sham.ssh;
+
+import static org.junit.Assert.assertTrue;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
+
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.openssl.PEMParser;
+import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+import org.junit.Test;
+
+import com.sshtools.client.SshClient.SshClientBuilder;
+import com.sshtools.common.publickey.InvalidPassphraseException;
+import com.sshtools.common.publickey.SshKeyPairGenerator;
+import com.sshtools.common.publickey.SshKeyUtils;
+import com.sshtools.common.publickey.SshPublicKeyFileFactory;
+import com.sshtools.common.ssh.SshException;
+import com.sshtools.common.ssh.components.SshKeyPair;
+import com.sshtools.common.ssh.components.SshPublicKey;
+
+import software.sham.MockSshServerTestSupport;
+
+public class MockSshServerConnectionTest extends MockSshServerTestSupport {
+
+ @Test(expected = IOException.class)
+ public void shouldDenyUnknownKey() throws IOException, InvalidPassphraseException, SshException {
+ // unknown to server
+ SshKeyPair keyPair = SshKeyUtils.getPrivateKey(Thread.currentThread().getContextClassLoader() //
+ .getResourceAsStream("keys/id_rsa_tester"), MockSshServerTestSupport.SSH_SERVER_USER_KEY_PASSPHRASE);
+
+ initSshClientWithKey(keyPair); // should throw IOException: Authentication failed
+ }
+
+ @Test
+ public void shouldSupportDERPublicKeyAuth() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException,
+ InvalidPassphraseException, SshException {
+ try (BufferedInputStream bufferedInputStream = new BufferedInputStream(
+ Thread.currentThread().getContextClassLoader().getResourceAsStream("keys/id_rsa_tester.der.pub"))) {
+
+ PublicKey publicKey = KeyFactory.getInstance("RSA")
+ .generatePublic(new X509EncodedKeySpec(bufferedInputStream.readAllBytes()));
+ testPublicKeyAuth(publicKey);
+ }
+ }
+
+ @Test
+ public void shouldSupportSSHPublicKeyAuth() throws IOException, InvalidPassphraseException, SshException {
+ SshPublicKey publicKey = SshKeyUtils.getPublicKey(Thread.currentThread().getContextClassLoader() //
+ .getResourceAsStream("keys/id_rsa_tester.pub"));
+
+ testPublicKeyAuth(publicKey.getJCEPublicKey());
+ }
+
+ @Test
+ public void shouldSupportPKCS8PublicKeyAuth()
+ throws IOException, URISyntaxException, SshException, InvalidPassphraseException {
+ try (PEMParser parser = new PEMParser(new InputStreamReader(
+ Thread.currentThread().getContextClassLoader().getResourceAsStream("keys/id_rsa_tester.pkcs8.pub")))) {
+ SubjectPublicKeyInfo subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(parser.readObject());
+ PublicKey publicKey = new JcaPEMKeyConverter().getPublicKey(subjectPublicKeyInfo);
+ testPublicKeyAuth(publicKey);
+ }
+ }
+
+ @Test
+ public void shouldSupportGeneratorPublicKeyAuth() throws IOException, SshException, InvalidPassphraseException {
+ final String privKeyPassword = "generated_testing";
+
+ File privKey = File.createTempFile("privKey", null);
+ Files.setPosixFilePermissions(privKey.toPath(), PosixFilePermissions.fromString("rw-" + "------"));
+ privKey.deleteOnExit();
+ File pubKey = File.createTempFile("pubKey", null);
+ pubKey.deleteOnExit();
+
+ SshKeyPair keyPair = SshKeyPairGenerator.generateKeyPair(SshKeyPairGenerator.SSH2_RSA);
+
+ SshKeyUtils.savePrivateKey(keyPair, privKeyPassword, null, privKey);
+ SshKeyUtils.createPublicKeyFile(keyPair.getPublicKey(), null, pubKey, SshPublicKeyFileFactory.OPENSSH_FORMAT);
+
+ SshKeyPair sshKeyPair = SshKeyUtils.getPrivateKey(privKey, privKeyPassword);
+ SshPublicKey publicKey = SshKeyUtils.getPublicKey(pubKey);
+
+ testPublicKeyAuth(sshKeyPair, publicKey.getJCEPublicKey());
+ }
+
+ private void initSshClientWithKey(SshKeyPair sshKeyPair) throws IOException, SshException {
+
+ this.sshClient = SshClientBuilder.create().withIdentities(sshKeyPair)
+ .withUsername(MockSshServerTestSupport.SSH_SERVER_USER)
+ .withTarget(MockSshServerTestSupport.SSH_SERVER_HOSTNAME, MockSshServerTestSupport.SSH_SERVER_PORT)
+ .build();
+
+ this.sshClient.openSessionChannel(MockSshServerTestSupport.SSH_SERVER_CONNECT_TIMOUT, true);
+ }
+
+ private void testPublicKeyAuth(PublicKey publicKey) throws IOException, InvalidPassphraseException, SshException {
+ SshKeyPair keyPair = SshKeyUtils.getPrivateKey(Thread.currentThread().getContextClassLoader() //
+ .getResourceAsStream("keys/id_rsa_tester"), MockSshServerTestSupport.SSH_SERVER_USER_KEY_PASSPHRASE);
+
+ testPublicKeyAuth(keyPair, publicKey);
+ }
+
+ private void testPublicKeyAuth(SshKeyPair keyPair, PublicKey publicKey) throws IOException, SshException {
+ server.allowPublicKey(publicKey);
+
+ initSshClientWithKey(keyPair);
+
+ assertTrue(this.sshClient.isAuthenticated());
+ }
+
+}
diff --git a/src/test/java/software/sham/ssh/MockSshShellTest.java b/src/test/java/software/sham/ssh/MockSshShellTest.java
new file mode 100644
index 0000000..e8c3ca4
--- /dev/null
+++ b/src/test/java/software/sham/ssh/MockSshShellTest.java
@@ -0,0 +1,107 @@
+package software.sham.ssh;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+
+import org.awaitility.Awaitility;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import com.sshtools.client.shell.ExpectShell;
+import com.sshtools.client.tasks.ShellTask;
+import com.sshtools.client.tasks.ShellTask.ShellTaskBuilder;
+import com.sshtools.client.tasks.Task;
+import com.sshtools.common.logger.Log;
+import com.sshtools.common.logger.Log.Level;
+import com.sshtools.common.ssh.RequestFuture;
+import com.sshtools.common.ssh.SshException;
+
+import software.sham.MockSshServerTestSupport;
+
+public class MockSshShellTest extends MockSshServerTestSupport {
+
+ private static final long TASK_TIMEOUT = 5000L;
+
+ public MockSshShellTest() {
+ super(server -> {
+ server.enableShell();
+ });
+ }
+
+ @BeforeClass
+ public static void setLoggers() {
+ Log.enableConsole(Level.DEBUG);
+ // see src/test/resources/simplelogger.properties
+ }
+
+ @Before
+ public void initSshClientWithPassword() throws SshException, IOException {
+ super.initSshClientWithPassword();
+ }
+
+ @Test
+ public void shouldBeGreetedAndExit() throws IOException {
+ runInShell(shell -> {
+ // force shell readiness with first command
+ assertEquals("Hi", shell.executeWithOutput("echo Hi"));
+ });
+
+ Awaitility.await().until(() -> Boolean.FALSE.equals(sshClient.isConnected()));
+ }
+
+ @Test
+ public void differentOutputForDifferentInput() throws IOException {
+ server.respondTo("Something wicked this way comes").withOutput("default");
+ server.respondTo("Knock knock").withOutput("Who's there?");
+
+ runInShell(shell -> {
+ assertEquals("default", shell.executeWithOutput("Something wicked this way comes"));
+
+ assertEquals("Who's there?", shell.executeWithOutput("Knock knock"));
+ });
+ }
+
+ @Test
+ public void delayedOutput() throws IOException {
+ server.respondTo("delayedResponder").withOutput("Starting..." + System.lineSeparator()).withDelay(500L)
+ .withOutput("Completed.");
+
+ runInShell(shell -> {
+ assertEquals("Starting..." + System.lineSeparator() + System.lineSeparator() + "Completed.",
+ shell.executeWithOutput("delayedResponder"));
+ });
+ }
+
+ /**
+ * SMI for testing purpose
+ */
+ interface TestInShell {
+ void test(ExpectShell shell) throws IOException, SshException;
+ }
+
+ private void runInShell(TestInShell shellTest) throws IOException {
+ ShellTask task = ShellTaskBuilder.create() //
+ .withClient(sshClient) //
+ .onTask((t, session) -> {
+ ExpectShell shell = new ExpectShell(t, ExpectShell.OS_LINUX);
+
+ shellTest.test(shell);
+
+ // potentially forceful quitting
+ shell.exit();
+ }) //
+ .build();
+
+ Task clientTask = sshClient.addTask(task);
+
+ RequestFuture requestFuture = clientTask // .waitForever();
+ .waitFor(TASK_TIMEOUT);
+
+ assertTrue(clientTask.getLastError() != null ? clientTask.getLastError().toString() : "No success.", //
+ requestFuture.isDoneAndSuccess());
+ }
+
+}
\ No newline at end of file
diff --git a/src/test/resources/keys/id_rsa_tester.pub b/src/test/resources/keys/id_rsa_tester.pub
index adbdda2..4df1406 100644
--- a/src/test/resources/keys/id_rsa_tester.pub
+++ b/src/test/resources/keys/id_rsa_tester.pub
@@ -1 +1 @@
-ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3k9SgCm39U8P8Dlh2aV2bWACmjMaRabfIbsxMdoH0Fy6qdGqgs8PYewAkiJnnjewoicbjY06NPx0KAsk9JEBKN0rJsm5auOiKVhmFoGQYUffbO7HPsMBhY0wtvYQCEKufqP5wXe3ml9DT+SoGaSdOgOd8nEu7P2DuH8NCF0x2bEM1xK4NsiOl3Zzc6ebgQ8S5NmbiW8Hh78GowKrx3TLRG38NySNgZWBwkKttsN6upzDZPA8WZ2TVB/o13a/PT+mXQUKPqg0liJVLl7qhWiOFqp879Wqjv4LD9mIfaK7KRszEs9qQDtxXRlSKhuXea/RgFQaoaAu5MVKo2hO3ClC9 rhoegg@hoegg-air
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3k9SgCm39U8P8Dlh2aV2bWACmjMaRabfIbsxMdoH0Fy6qdGqgs8PYewAkiJnnjewoicbjY06NPx0KAsk9JEBKN0rJsm5auOiKVhmFoGQYUffbO7HPsMBhY0wtvYQCEKufqP5wXe3ml9DT+SoGaSdOgOd8nEu7P2DuH8NCF0x2bEM1xK4NsiOl3Zzc6ebgQ8S5NmbiW8Hh78GowKrx3TLRG38NySNgZWBwkKttsN6upzDZPA8WZ2TVB/o13a/PT+mXQUKPqg0liJVLl7qhWiOFqp879Wqjv4LD9mIfaK7KRszEs9qQDtxXRlSKhuXea/RgFQaoaAu5MVKo2hO3ClC9 rhoegg@hoegg-air
\ No newline at end of file
diff --git a/src/test/resources/log4j2.xml b/src/test/resources/log4j2.xml
deleted file mode 100644
index b01602f..0000000
--- a/src/test/resources/log4j2.xml
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/test/resources/simplelogger.properties b/src/test/resources/simplelogger.properties
new file mode 100644
index 0000000..5c89a5a
--- /dev/null
+++ b/src/test/resources/simplelogger.properties
@@ -0,0 +1 @@
+org.slf4j.simpleLogger.defaultLogLevel=DEBUG
\ No newline at end of file