/* * Copyright (C) 2018, 2020 Thomas Wolf 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 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.BufferedWriter; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Collections; import java.util.Iterator; import java.util.List; import org.apache.sshd.common.config.keys.PublicKeyEntry; 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; /** * 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; /** * @since 5.10 */ protected File publicKey2; protected SshTestGitServer server; private SshSessionFactory factory; protected int testPort; protected File knownHosts; @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(); } // The home directory is mocked here sshDir = new File(FS.DETECTED.userHome(), ".ssh"); assertTrue(sshDir.mkdir()); File serverDir = new File(getTemporaryDirectory(), "srv"); assertTrue(serverDir.mkdir()); // Create two key pairs. Let's not call them "id_rsa". KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); generator.initialize(2048); privateKey1 = new File(sshDir, "first_key"); privateKey2 = new File(sshDir, "second_key"); publicKey1 = createKeyPair(generator.generateKeyPair(), privateKey1); publicKey2 = createKeyPair(generator.generateKeyPair(), privateKey2); // Create a host key KeyPair hostKey = generator.generateKeyPair(); // Start a server with our test user and the first key. server = new SshTestGitServer(TEST_USER, publicKey1.toPath(), db, hostKey); testPort = server.start(); assertTrue(testPort > 0); knownHosts = new File(sshDir, "known_hosts"); StringBuilder knownHostsLine = new StringBuilder(); knownHostsLine.append("[localhost]:").append(testPort).append(' '); PublicKeyEntry.appendPublicKeyEntry(knownHostsLine, hostKey.getPublic()); Files.write(knownHosts.toPath(), Collections.singleton(knownHostsLine.toString())); factory = createSessionFactory(); SshSessionFactory.setInstance(factory); } private static File createKeyPair(KeyPair newKey, File privateKeyFile) throws Exception { // Write PKCS#8 PEM unencrypted. Both JSch and sshd can read that. PrivateKey privateKey = newKey.getPrivate(); String format = privateKey.getFormat(); if (!"PKCS#8".equalsIgnoreCase(format)) { throw new IOException("Cannot write " + privateKey.getAlgorithm() + " key in " + format + " format"); } try (BufferedWriter writer = Files.newBufferedWriter( privateKeyFile.toPath(), StandardCharsets.US_ASCII)) { writer.write("-----BEGIN PRIVATE KEY-----"); writer.newLine(); write(writer, privateKey.getEncoded(), 64); writer.write("-----END PRIVATE KEY-----"); writer.newLine(); } File publicKeyFile = new File(privateKeyFile.getParentFile(), privateKeyFile.getName() + ".pub"); StringBuilder builder = new StringBuilder(); PublicKeyEntry.appendPublicKeyEntry(builder, newKey.getPublic()); builder.append(' ').append(TEST_USER); try (OutputStream out = new FileOutputStream(publicKeyFile)) { out.write(builder.toString().getBytes(StandardCharsets.US_ASCII)); } return publicKeyFile; } private static void write(BufferedWriter out, byte[] bytes, int lineLength) throws IOException { String data = Base64.getEncoder().encodeToString(bytes); int last = data.length(); for (int i = 0; i < last; i += lineLength) { if (i + lineLength <= last) { out.write(data.substring(i, i + lineLength)); } else { out.write(data.substring(i)); } out.newLine(); } Arrays.fill(bytes, (byte) 0); } /** * 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 * if an IO error occurred */ protected static String createKnownHostsFile(File file, String host, int port, File publicKey) throws IOException { List lines = Files.readAllLines(publicKey.toPath(), StandardCharsets.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 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; } 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 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 stringStore; private final Iterator 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 log = new ArrayList<>(); private void logItems(URIish uri, CredentialItem... items) { log.add(new LogEntry(uri, Arrays.asList(items))); } public List getLog() { return log; } } protected static class LogEntry { private URIish uri; private List items; public LogEntry(URIish uri, List items) { this.uri = uri; this.items = items; } public URIish getURIish() { return uri; } public List getItems() { return items; } } }