xref: /JGit/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java (revision 566e49d7d39b12c785be24b8b61b4960a4b7ea17)
1 /*
2  * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
3  *
4  * This program and the accompanying materials are made available under the
5  * terms of the Eclipse Distribution License v. 1.0 which is available at
6  * https://www.eclipse.org/org/documents/edl-v10.php.
7  *
8  * SPDX-License-Identifier: BSD-3-Clause
9  */
10 package org.eclipse.jgit.junit.ssh;
11 
12 import static org.junit.Assert.assertEquals;
13 import static org.junit.Assert.assertFalse;
14 import static org.junit.Assert.assertNotEquals;
15 import static org.junit.Assert.assertNotNull;
16 import static org.junit.Assert.assertTrue;
17 
18 import java.io.BufferedWriter;
19 import java.io.File;
20 import java.io.FileOutputStream;
21 import java.io.IOException;
22 import java.io.InputStream;
23 import java.io.OutputStream;
24 import java.nio.charset.StandardCharsets;
25 import java.nio.file.Files;
26 import java.security.KeyPair;
27 import java.security.KeyPairGenerator;
28 import java.security.PrivateKey;
29 import java.util.ArrayList;
30 import java.util.Arrays;
31 import java.util.Base64;
32 import java.util.Collections;
33 import java.util.Iterator;
34 import java.util.List;
35 
36 import org.apache.sshd.common.config.keys.PublicKeyEntry;
37 import org.eclipse.jgit.api.CloneCommand;
38 import org.eclipse.jgit.api.Git;
39 import org.eclipse.jgit.api.PushCommand;
40 import org.eclipse.jgit.api.ResetCommand.ResetType;
41 import org.eclipse.jgit.errors.UnsupportedCredentialItem;
42 import org.eclipse.jgit.junit.RepositoryTestCase;
43 import org.eclipse.jgit.lib.Constants;
44 import org.eclipse.jgit.lib.Repository;
45 import org.eclipse.jgit.revwalk.RevCommit;
46 import org.eclipse.jgit.transport.CredentialItem;
47 import org.eclipse.jgit.transport.CredentialsProvider;
48 import org.eclipse.jgit.transport.PushResult;
49 import org.eclipse.jgit.transport.RemoteRefUpdate;
50 import org.eclipse.jgit.transport.SshSessionFactory;
51 import org.eclipse.jgit.transport.URIish;
52 import org.eclipse.jgit.util.FS;
53 import org.junit.After;
54 
55 /**
56  * Root class for ssh tests. Sets up the ssh test server. A set of pre-computed
57  * keys for testing is provided in the bundle and can be used in test cases via
58  * {@link #copyTestResource(String, File)}. These test key files names have four
59  * components, separated by a single underscore: "id", the algorithm, the bits
60  * (if variable), and the password if the private key is encrypted. For instance
61  * "{@code id_ecdsa_384_testpass}" is an encrypted ECDSA-384 key. The passphrase
62  * to decrypt is "testpass". The key "{@code id_ecdsa_384}" is the same but
63  * unencrypted. All keys were generated and encrypted via ssh-keygen. Note that
64  * DSA and ec25519 have no "bits" component. Available keys are listed in
65  * {@link SshTestBase#KEY_RESOURCES}.
66  */
67 public abstract class SshTestHarness extends RepositoryTestCase {
68 
69 	protected static final String TEST_USER = "testuser";
70 
71 	protected File sshDir;
72 
73 	protected File privateKey1;
74 
75 	protected File privateKey2;
76 
77 	protected File publicKey1;
78 
79 	protected File publicKey2;
80 
81 	protected SshTestGitServer server;
82 
83 	private SshSessionFactory factory;
84 
85 	protected int testPort;
86 
87 	protected File knownHosts;
88 
89 	private File homeDir;
90 
91 	@Override
setUp()92 	public void setUp() throws Exception {
93 		super.setUp();
94 		writeTrashFile("file.txt", "something");
95 		try (Git git = new Git(db)) {
96 			git.add().addFilepattern("file.txt").call();
97 			git.commit().setMessage("Initial commit").call();
98 		}
99 		mockSystemReader.setProperty("user.home",
100 				getTemporaryDirectory().getAbsolutePath());
101 		mockSystemReader.setProperty("HOME",
102 				getTemporaryDirectory().getAbsolutePath());
103 		homeDir = FS.DETECTED.userHome();
104 		FS.DETECTED.setUserHome(getTemporaryDirectory().getAbsoluteFile());
105 		sshDir = new File(getTemporaryDirectory(), ".ssh");
106 		assertTrue(sshDir.mkdir());
107 		File serverDir = new File(getTemporaryDirectory(), "srv");
108 		assertTrue(serverDir.mkdir());
109 		// Create two key pairs. Let's not call them "id_rsa".
110 		KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
111 		generator.initialize(2048);
112 		privateKey1 = new File(sshDir, "first_key");
113 		privateKey2 = new File(sshDir, "second_key");
114 		publicKey1 = createKeyPair(generator.generateKeyPair(), privateKey1);
115 		publicKey2 = createKeyPair(generator.generateKeyPair(), privateKey2);
116 		// Create a host key
117 		KeyPair hostKey = generator.generateKeyPair();
118 		// Start a server with our test user and the first key.
119 		server = new SshTestGitServer(TEST_USER, publicKey1.toPath(), db,
120 				hostKey);
121 		testPort = server.start();
122 		assertTrue(testPort > 0);
123 		knownHosts = new File(sshDir, "known_hosts");
124 		StringBuilder knownHostsLine = new StringBuilder();
125 		knownHostsLine.append("[localhost]:").append(testPort).append(' ');
126 		PublicKeyEntry.appendPublicKeyEntry(knownHostsLine,
127 				hostKey.getPublic());
128 		Files.write(knownHosts.toPath(),
129 				Collections.singleton(knownHostsLine.toString()));
130 		factory = createSessionFactory();
131 		SshSessionFactory.setInstance(factory);
132 	}
133 
createKeyPair(KeyPair newKey, File privateKeyFile)134 	private static File createKeyPair(KeyPair newKey, File privateKeyFile)
135 			throws Exception {
136 		// Write PKCS#8 PEM unencrypted. Both JSch and sshd can read that.
137 		PrivateKey privateKey = newKey.getPrivate();
138 		String format = privateKey.getFormat();
139 		if (!"PKCS#8".equalsIgnoreCase(format)) {
140 			throw new IOException("Cannot write " + privateKey.getAlgorithm()
141 					+ " key in " + format + " format");
142 		}
143 		try (BufferedWriter writer = Files.newBufferedWriter(
144 				privateKeyFile.toPath(), StandardCharsets.US_ASCII)) {
145 			writer.write("-----BEGIN PRIVATE KEY-----");
146 			writer.newLine();
147 			write(writer, privateKey.getEncoded(), 64);
148 			writer.write("-----END PRIVATE KEY-----");
149 			writer.newLine();
150 		}
151 		File publicKeyFile = new File(privateKeyFile.getParentFile(),
152 				privateKeyFile.getName() + ".pub");
153 		StringBuilder builder = new StringBuilder();
154 		PublicKeyEntry.appendPublicKeyEntry(builder, newKey.getPublic());
155 		builder.append(' ').append(TEST_USER);
156 		try (OutputStream out = new FileOutputStream(publicKeyFile)) {
157 			out.write(builder.toString().getBytes(StandardCharsets.US_ASCII));
158 		}
159 		return publicKeyFile;
160 	}
161 
write(BufferedWriter out, byte[] bytes, int lineLength)162 	private static void write(BufferedWriter out, byte[] bytes, int lineLength)
163 			throws IOException {
164 		String data = Base64.getEncoder().encodeToString(bytes);
165 		int last = data.length();
166 		for (int i = 0; i < last; i += lineLength) {
167 			if (i + lineLength <= last) {
168 				out.write(data.substring(i, i + lineLength));
169 			} else {
170 				out.write(data.substring(i));
171 			}
172 			out.newLine();
173 		}
174 		Arrays.fill(bytes, (byte) 0);
175 	}
176 
177 	/**
178 	 * Creates a new known_hosts file with one entry for the given host and port
179 	 * taken from the given public key file.
180 	 *
181 	 * @param file
182 	 *            to write the known_hosts file to
183 	 * @param host
184 	 *            for the entry
185 	 * @param port
186 	 *            for the entry
187 	 * @param publicKey
188 	 *            to use
189 	 * @return the public-key part of the line
190 	 * @throws IOException
191 	 */
createKnownHostsFile(File file, String host, int port, File publicKey)192 	protected static String createKnownHostsFile(File file, String host,
193 			int port, File publicKey) throws IOException {
194 		List<String> lines = Files.readAllLines(publicKey.toPath(),
195 				StandardCharsets.UTF_8);
196 		assertEquals("Public key has too many lines", 1, lines.size());
197 		String pubKey = lines.get(0);
198 		// Strip off the comment.
199 		String[] parts = pubKey.split("\\s+");
200 		assertTrue("Unexpected key content",
201 				parts.length == 2 || parts.length == 3);
202 		String keyPart = parts[0] + ' ' + parts[1];
203 		String line = '[' + host + "]:" + port + ' ' + keyPart;
204 		Files.write(file.toPath(), Collections.singletonList(line));
205 		return keyPart;
206 	}
207 
208 	/**
209 	 * Checks whether there is a line for the given host and port that also
210 	 * matches the given key part in the list of lines.
211 	 *
212 	 * @param host
213 	 *            to look for
214 	 * @param port
215 	 *            to look for
216 	 * @param keyPart
217 	 *            to look for
218 	 * @param lines
219 	 *            to look in
220 	 * @return {@code true} if found, {@code false} otherwise
221 	 */
hasHostKey(String host, int port, String keyPart, List<String> lines)222 	protected boolean hasHostKey(String host, int port, String keyPart,
223 			List<String> lines) {
224 		String h = '[' + host + "]:" + port;
225 		return lines.stream()
226 				.anyMatch(l -> l.contains(h) && l.contains(keyPart));
227 	}
228 
229 	@After
shutdownServer()230 	public void shutdownServer() throws Exception {
231 		if (server != null) {
232 			server.stop();
233 			server = null;
234 		}
235 		FS.DETECTED.setUserHome(homeDir);
236 		SshSessionFactory.setInstance(null);
237 		factory = null;
238 	}
239 
createSessionFactory()240 	protected abstract SshSessionFactory createSessionFactory();
241 
getSessionFactory()242 	protected SshSessionFactory getSessionFactory() {
243 		return factory;
244 	}
245 
installConfig(String... config)246 	protected abstract void installConfig(String... config);
247 
248 	/**
249 	 * Copies a test data file contained in the test bundle to the given file.
250 	 * Equivalent to {@link #copyTestResource(Class, String, File)} with
251 	 * {@code SshTestHarness.class} as first parameter.
252 	 *
253 	 * @param resourceName
254 	 *            of the test resource to copy
255 	 * @param to
256 	 *            file to copy the resource to
257 	 * @throws IOException
258 	 *             if the resource cannot be copied
259 	 */
copyTestResource(String resourceName, File to)260 	protected void copyTestResource(String resourceName, File to)
261 			throws IOException {
262 		copyTestResource(SshTestHarness.class, resourceName, to);
263 	}
264 
265 	/**
266 	 * Copies a test data file contained in the test bundle to the given file,
267 	 * using {@link Class#getResourceAsStream(String)} to get the test resource.
268 	 *
269 	 * @param loader
270 	 *            {@link Class} to use to load the resource
271 	 * @param resourceName
272 	 *            of the test resource to copy
273 	 * @param to
274 	 *            file to copy the resource to
275 	 * @throws IOException
276 	 *             if the resource cannot be copied
277 	 */
copyTestResource(Class<?> loader, String resourceName, File to)278 	protected void copyTestResource(Class<?> loader, String resourceName,
279 			File to) throws IOException {
280 		try (InputStream in = loader.getResourceAsStream(resourceName)) {
281 			Files.copy(in, to.toPath());
282 		}
283 	}
284 
cloneWith(String uri, File to, CredentialsProvider provider, String... config)285 	protected File cloneWith(String uri, File to, CredentialsProvider provider,
286 			String... config) throws Exception {
287 		installConfig(config);
288 		CloneCommand clone = Git.cloneRepository().setCloneAllBranches(true)
289 				.setDirectory(to).setURI(uri);
290 		if (provider != null) {
291 			clone.setCredentialsProvider(provider);
292 		}
293 		try (Git git = clone.call()) {
294 			Repository repo = git.getRepository();
295 			assertNotNull(repo.resolve("master"));
296 			assertNotEquals(db.getWorkTree(),
297 					git.getRepository().getWorkTree());
298 			assertTrue(new File(git.getRepository().getWorkTree(), "file.txt")
299 					.exists());
300 			return repo.getWorkTree();
301 		}
302 	}
303 
pushTo(File localClone)304 	protected void pushTo(File localClone) throws Exception {
305 		pushTo(null, localClone);
306 	}
307 
pushTo(CredentialsProvider provider, File localClone)308 	protected void pushTo(CredentialsProvider provider, File localClone)
309 			throws Exception {
310 		RevCommit commit;
311 		File newFile = null;
312 		try (Git git = Git.open(localClone)) {
313 			// Write a new file and modify a file.
314 			Repository local = git.getRepository();
315 			newFile = File.createTempFile("new", "sshtest",
316 					local.getWorkTree());
317 			write(newFile, "something new");
318 			File existingFile = new File(local.getWorkTree(), "file.txt");
319 			write(existingFile, "something else");
320 			git.add().addFilepattern("file.txt")
321 					.addFilepattern(newFile.getName())
322 					.call();
323 			commit = git.commit().setMessage("Local commit").call();
324 			// Push
325 			PushCommand push = git.push().setPushAll();
326 			if (provider != null) {
327 				push.setCredentialsProvider(provider);
328 			}
329 			Iterable<PushResult> results = push.call();
330 			for (PushResult result : results) {
331 				for (RemoteRefUpdate u : result.getRemoteUpdates()) {
332 					assertEquals(
333 							"Could not update " + u.getRemoteName() + ' '
334 									+ u.getMessage(),
335 							RemoteRefUpdate.Status.OK, u.getStatus());
336 				}
337 			}
338 		}
339 		// Now check "master" in the remote repo directly:
340 		assertEquals("Unexpected remote commit", commit, db.resolve("master"));
341 		assertEquals("Unexpected remote commit", commit,
342 				db.resolve(Constants.HEAD));
343 		File remoteFile = new File(db.getWorkTree(), newFile.getName());
344 		assertFalse("File should not exist on remote", remoteFile.exists());
345 		try (Git git = new Git(db)) {
346 			git.reset().setMode(ResetType.HARD).setRef(Constants.HEAD).call();
347 		}
348 		assertTrue("File does not exist on remote", remoteFile.exists());
349 		checkFile(remoteFile, "something new");
350 	}
351 
352 	protected static class TestCredentialsProvider extends CredentialsProvider {
353 
354 		private final List<String> stringStore;
355 
356 		private final Iterator<String> strings;
357 
TestCredentialsProvider(String... strings)358 		public TestCredentialsProvider(String... strings) {
359 			if (strings == null || strings.length == 0) {
360 				stringStore = Collections.emptyList();
361 			} else {
362 				stringStore = Arrays.asList(strings);
363 			}
364 			this.strings = stringStore.iterator();
365 		}
366 
367 		@Override
isInteractive()368 		public boolean isInteractive() {
369 			return true;
370 		}
371 
372 		@Override
supports(CredentialItem... items)373 		public boolean supports(CredentialItem... items) {
374 			return true;
375 		}
376 
377 		@Override
get(URIish uri, CredentialItem... items)378 		public boolean get(URIish uri, CredentialItem... items)
379 				throws UnsupportedCredentialItem {
380 			System.out.println("URI: " + uri);
381 			for (CredentialItem item : items) {
382 				System.out.println(item.getClass().getSimpleName() + ' '
383 						+ item.getPromptText());
384 			}
385 			logItems(uri, items);
386 			for (CredentialItem item : items) {
387 				if (item instanceof CredentialItem.InformationalMessage) {
388 					continue;
389 				}
390 				if (item instanceof CredentialItem.YesNoType) {
391 					((CredentialItem.YesNoType) item).setValue(true);
392 				} else if (item instanceof CredentialItem.CharArrayType) {
393 					if (strings.hasNext()) {
394 						((CredentialItem.CharArrayType) item)
395 								.setValue(strings.next().toCharArray());
396 					} else {
397 						return false;
398 					}
399 				} else if (item instanceof CredentialItem.StringType) {
400 					if (strings.hasNext()) {
401 						((CredentialItem.StringType) item)
402 								.setValue(strings.next());
403 					} else {
404 						return false;
405 					}
406 				} else {
407 					return false;
408 				}
409 			}
410 			return true;
411 		}
412 
413 		private List<LogEntry> log = new ArrayList<>();
414 
logItems(URIish uri, CredentialItem... items)415 		private void logItems(URIish uri, CredentialItem... items) {
416 			log.add(new LogEntry(uri, Arrays.asList(items)));
417 		}
418 
getLog()419 		public List<LogEntry> getLog() {
420 			return log;
421 		}
422 	}
423 
424 	protected static class LogEntry {
425 
426 		private URIish uri;
427 
428 		private List<CredentialItem> items;
429 
LogEntry(URIish uri, List<CredentialItem> items)430 		public LogEntry(URIish uri, List<CredentialItem> items) {
431 			this.uri = uri;
432 			this.items = items;
433 		}
434 
getURIish()435 		public URIish getURIish() {
436 			return uri;
437 		}
438 
getItems()439 		public List<CredentialItem> getItems() {
440 			return items;
441 		}
442 	}
443 }
444