| /* |
| * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others |
| * |
| * This program and the accompanying materials are made available under the |
| * terms of the Eclipse Distribution License v. 1.0 which is available at |
| * https://www.eclipse.org/org/documents/edl-v10.php. |
| * |
| * SPDX-License-Identifier: BSD-3-Clause |
| */ |
| package org.eclipse.jgit.junit.ssh; |
| |
| import static java.nio.charset.StandardCharsets.US_ASCII; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| import static org.junit.Assert.assertEquals; |
| import static org.junit.Assert.assertFalse; |
| import static org.junit.Assert.assertNotEquals; |
| import static org.junit.Assert.assertNotNull; |
| import static org.junit.Assert.assertTrue; |
| |
| import java.io.ByteArrayOutputStream; |
| import java.io.File; |
| import java.io.FileOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.nio.file.Files; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.Iterator; |
| import java.util.List; |
| |
| import org.eclipse.jgit.api.CloneCommand; |
| import org.eclipse.jgit.api.Git; |
| import org.eclipse.jgit.api.PushCommand; |
| import org.eclipse.jgit.api.ResetCommand.ResetType; |
| import org.eclipse.jgit.errors.UnsupportedCredentialItem; |
| import org.eclipse.jgit.junit.RepositoryTestCase; |
| import org.eclipse.jgit.lib.Constants; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.transport.CredentialItem; |
| import org.eclipse.jgit.transport.CredentialsProvider; |
| import org.eclipse.jgit.transport.PushResult; |
| import org.eclipse.jgit.transport.RemoteRefUpdate; |
| import org.eclipse.jgit.transport.SshSessionFactory; |
| import org.eclipse.jgit.transport.URIish; |
| import org.eclipse.jgit.util.FS; |
| import org.junit.After; |
| |
| import com.jcraft.jsch.JSch; |
| import com.jcraft.jsch.KeyPair; |
| |
| /** |
| * Root class for ssh tests. Sets up the ssh test server. A set of pre-computed |
| * keys for testing is provided in the bundle and can be used in test cases via |
| * {@link #copyTestResource(String, File)}. These test key files names have four |
| * components, separated by a single underscore: "id", the algorithm, the bits |
| * (if variable), and the password if the private key is encrypted. For instance |
| * "{@code id_ecdsa_384_testpass}" is an encrypted ECDSA-384 key. The passphrase |
| * to decrypt is "testpass". The key "{@code id_ecdsa_384}" is the same but |
| * unencrypted. All keys were generated and encrypted via ssh-keygen. Note that |
| * DSA and ec25519 have no "bits" component. Available keys are listed in |
| * {@link SshTestBase#KEY_RESOURCES}. |
| */ |
| public abstract class SshTestHarness extends RepositoryTestCase { |
| |
| protected static final String TEST_USER = "testuser"; |
| |
| protected File sshDir; |
| |
| protected File privateKey1; |
| |
| protected File privateKey2; |
| |
| protected File publicKey1; |
| |
| protected SshTestGitServer server; |
| |
| private SshSessionFactory factory; |
| |
| protected int testPort; |
| |
| protected File knownHosts; |
| |
| private File homeDir; |
| |
| @Override |
| public void setUp() throws Exception { |
| super.setUp(); |
| writeTrashFile("file.txt", "something"); |
| try (Git git = new Git(db)) { |
| git.add().addFilepattern("file.txt").call(); |
| git.commit().setMessage("Initial commit").call(); |
| } |
| mockSystemReader.setProperty("user.home", |
| getTemporaryDirectory().getAbsolutePath()); |
| mockSystemReader.setProperty("HOME", |
| getTemporaryDirectory().getAbsolutePath()); |
| homeDir = FS.DETECTED.userHome(); |
| FS.DETECTED.setUserHome(getTemporaryDirectory().getAbsoluteFile()); |
| sshDir = new File(getTemporaryDirectory(), ".ssh"); |
| assertTrue(sshDir.mkdir()); |
| File serverDir = new File(getTemporaryDirectory(), "srv"); |
| assertTrue(serverDir.mkdir()); |
| // Create two key pairs. Let's not call them "id_rsa". |
| privateKey1 = new File(sshDir, "first_key"); |
| privateKey2 = new File(sshDir, "second_key"); |
| publicKey1 = createKeyPair(privateKey1); |
| createKeyPair(privateKey2); |
| ByteArrayOutputStream publicHostKey = new ByteArrayOutputStream(); |
| // Start a server with our test user and the first key. |
| server = new SshTestGitServer(TEST_USER, publicKey1.toPath(), db, |
| createHostKey(publicHostKey)); |
| testPort = server.start(); |
| assertTrue(testPort > 0); |
| knownHosts = new File(sshDir, "known_hosts"); |
| Files.write(knownHosts.toPath(), Collections.singleton("[localhost]:" |
| + testPort + ' ' |
| + publicHostKey.toString(US_ASCII.name()))); |
| factory = createSessionFactory(); |
| SshSessionFactory.setInstance(factory); |
| } |
| |
| private static File createKeyPair(File privateKeyFile) throws Exception { |
| // Found no way to do this with MINA sshd except rolling it all |
| // ourselves... |
| JSch jsch = new JSch(); |
| KeyPair pair = KeyPair.genKeyPair(jsch, KeyPair.RSA, 2048); |
| try (OutputStream out = new FileOutputStream(privateKeyFile)) { |
| pair.writePrivateKey(out); |
| } |
| File publicKeyFile = new File(privateKeyFile.getParentFile(), |
| privateKeyFile.getName() + ".pub"); |
| try (OutputStream out = new FileOutputStream(publicKeyFile)) { |
| pair.writePublicKey(out, TEST_USER); |
| } |
| return publicKeyFile; |
| } |
| |
| private static byte[] createHostKey(OutputStream publicKey) |
| throws Exception { |
| JSch jsch = new JSch(); |
| KeyPair pair = KeyPair.genKeyPair(jsch, KeyPair.RSA, 2048); |
| pair.writePublicKey(publicKey, ""); |
| try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { |
| pair.writePrivateKey(out); |
| out.flush(); |
| return out.toByteArray(); |
| } |
| } |
| |
| /** |
| * Creates a new known_hosts file with one entry for the given host and port |
| * taken from the given public key file. |
| * |
| * @param file |
| * to write the known_hosts file to |
| * @param host |
| * for the entry |
| * @param port |
| * for the entry |
| * @param publicKey |
| * to use |
| * @return the public-key part of the line |
| * @throws IOException |
| */ |
| protected static String createKnownHostsFile(File file, String host, |
| int port, File publicKey) throws IOException { |
| List<String> lines = Files.readAllLines(publicKey.toPath(), UTF_8); |
| assertEquals("Public key has too many lines", 1, lines.size()); |
| String pubKey = lines.get(0); |
| // Strip off the comment. |
| String[] parts = pubKey.split("\\s+"); |
| assertTrue("Unexpected key content", |
| parts.length == 2 || parts.length == 3); |
| String keyPart = parts[0] + ' ' + parts[1]; |
| String line = '[' + host + "]:" + port + ' ' + keyPart; |
| Files.write(file.toPath(), Collections.singletonList(line)); |
| return keyPart; |
| } |
| |
| /** |
| * Checks whether there is a line for the given host and port that also |
| * matches the given key part in the list of lines. |
| * |
| * @param host |
| * to look for |
| * @param port |
| * to look for |
| * @param keyPart |
| * to look for |
| * @param lines |
| * to look in |
| * @return {@code true} if found, {@code false} otherwise |
| */ |
| protected boolean hasHostKey(String host, int port, String keyPart, |
| List<String> lines) { |
| String h = '[' + host + "]:" + port; |
| return lines.stream() |
| .anyMatch(l -> l.contains(h) && l.contains(keyPart)); |
| } |
| |
| @After |
| public void shutdownServer() throws Exception { |
| if (server != null) { |
| server.stop(); |
| server = null; |
| } |
| FS.DETECTED.setUserHome(homeDir); |
| SshSessionFactory.setInstance(null); |
| factory = null; |
| } |
| |
| protected abstract SshSessionFactory createSessionFactory(); |
| |
| protected SshSessionFactory getSessionFactory() { |
| return factory; |
| } |
| |
| protected abstract void installConfig(String... config); |
| |
| /** |
| * Copies a test data file contained in the test bundle to the given file. |
| * Equivalent to {@link #copyTestResource(Class, String, File)} with |
| * {@code SshTestHarness.class} as first parameter. |
| * |
| * @param resourceName |
| * of the test resource to copy |
| * @param to |
| * file to copy the resource to |
| * @throws IOException |
| * if the resource cannot be copied |
| */ |
| protected void copyTestResource(String resourceName, File to) |
| throws IOException { |
| copyTestResource(SshTestHarness.class, resourceName, to); |
| } |
| |
| /** |
| * Copies a test data file contained in the test bundle to the given file, |
| * using {@link Class#getResourceAsStream(String)} to get the test resource. |
| * |
| * @param loader |
| * {@link Class} to use to load the resource |
| * @param resourceName |
| * of the test resource to copy |
| * @param to |
| * file to copy the resource to |
| * @throws IOException |
| * if the resource cannot be copied |
| */ |
| protected void copyTestResource(Class<?> loader, String resourceName, |
| File to) throws IOException { |
| try (InputStream in = loader.getResourceAsStream(resourceName)) { |
| Files.copy(in, to.toPath()); |
| } |
| } |
| |
| protected File cloneWith(String uri, File to, CredentialsProvider provider, |
| String... config) throws Exception { |
| installConfig(config); |
| CloneCommand clone = Git.cloneRepository().setCloneAllBranches(true) |
| .setDirectory(to).setURI(uri); |
| if (provider != null) { |
| clone.setCredentialsProvider(provider); |
| } |
| try (Git git = clone.call()) { |
| Repository repo = git.getRepository(); |
| assertNotNull(repo.resolve("master")); |
| assertNotEquals(db.getWorkTree(), |
| git.getRepository().getWorkTree()); |
| assertTrue(new File(git.getRepository().getWorkTree(), "file.txt") |
| .exists()); |
| return repo.getWorkTree(); |
| } |
| } |
| |
| protected void pushTo(File localClone) throws Exception { |
| pushTo(null, localClone); |
| } |
| |
| protected void pushTo(CredentialsProvider provider, File localClone) |
| throws Exception { |
| RevCommit commit; |
| File newFile = null; |
| try (Git git = Git.open(localClone)) { |
| // Write a new file and modify a file. |
| Repository local = git.getRepository(); |
| newFile = File.createTempFile("new", "sshtest", |
| local.getWorkTree()); |
| write(newFile, "something new"); |
| File existingFile = new File(local.getWorkTree(), "file.txt"); |
| write(existingFile, "something else"); |
| git.add().addFilepattern("file.txt") |
| .addFilepattern(newFile.getName()) |
| .call(); |
| commit = git.commit().setMessage("Local commit").call(); |
| // Push |
| PushCommand push = git.push().setPushAll(); |
| if (provider != null) { |
| push.setCredentialsProvider(provider); |
| } |
| Iterable<PushResult> results = push.call(); |
| for (PushResult result : results) { |
| for (RemoteRefUpdate u : result.getRemoteUpdates()) { |
| assertEquals( |
| "Could not update " + u.getRemoteName() + ' ' |
| + u.getMessage(), |
| RemoteRefUpdate.Status.OK, u.getStatus()); |
| } |
| } |
| } |
| // Now check "master" in the remote repo directly: |
| assertEquals("Unexpected remote commit", commit, db.resolve("master")); |
| assertEquals("Unexpected remote commit", commit, |
| db.resolve(Constants.HEAD)); |
| File remoteFile = new File(db.getWorkTree(), newFile.getName()); |
| assertFalse("File should not exist on remote", remoteFile.exists()); |
| try (Git git = new Git(db)) { |
| git.reset().setMode(ResetType.HARD).setRef(Constants.HEAD).call(); |
| } |
| assertTrue("File does not exist on remote", remoteFile.exists()); |
| checkFile(remoteFile, "something new"); |
| } |
| |
| protected static class TestCredentialsProvider extends CredentialsProvider { |
| |
| private final List<String> stringStore; |
| |
| private final Iterator<String> strings; |
| |
| public TestCredentialsProvider(String... strings) { |
| if (strings == null || strings.length == 0) { |
| stringStore = Collections.emptyList(); |
| } else { |
| stringStore = Arrays.asList(strings); |
| } |
| this.strings = stringStore.iterator(); |
| } |
| |
| @Override |
| public boolean isInteractive() { |
| return true; |
| } |
| |
| @Override |
| public boolean supports(CredentialItem... items) { |
| return true; |
| } |
| |
| @Override |
| public boolean get(URIish uri, CredentialItem... items) |
| throws UnsupportedCredentialItem { |
| System.out.println("URI: " + uri); |
| for (CredentialItem item : items) { |
| System.out.println(item.getClass().getSimpleName() + ' ' |
| + item.getPromptText()); |
| } |
| logItems(uri, items); |
| for (CredentialItem item : items) { |
| if (item instanceof CredentialItem.InformationalMessage) { |
| continue; |
| } |
| if (item instanceof CredentialItem.YesNoType) { |
| ((CredentialItem.YesNoType) item).setValue(true); |
| } else if (item instanceof CredentialItem.CharArrayType) { |
| if (strings.hasNext()) { |
| ((CredentialItem.CharArrayType) item) |
| .setValue(strings.next().toCharArray()); |
| } else { |
| return false; |
| } |
| } else if (item instanceof CredentialItem.StringType) { |
| if (strings.hasNext()) { |
| ((CredentialItem.StringType) item) |
| .setValue(strings.next()); |
| } else { |
| return false; |
| } |
| } else { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| private List<LogEntry> log = new ArrayList<>(); |
| |
| private void logItems(URIish uri, CredentialItem... items) { |
| log.add(new LogEntry(uri, Arrays.asList(items))); |
| } |
| |
| public List<LogEntry> getLog() { |
| return log; |
| } |
| } |
| |
| protected static class LogEntry { |
| |
| private URIish uri; |
| |
| private List<CredentialItem> items; |
| |
| public LogEntry(URIish uri, List<CredentialItem> items) { |
| this.uri = uri; |
| this.items = items; |
| } |
| |
| public URIish getURIish() { |
| return uri; |
| } |
| |
| public List<CredentialItem> getItems() { |
| return items; |
| } |
| } |
| } |