| /* | |
| * Copyright (C) 2018, 2020 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 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, 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<String> 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<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; | |
| } | |
| 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; | |
| } | |
| } | |
| } |