View Javadoc
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 SshTestGitServer server;
80  
81  	private SshSessionFactory factory;
82  
83  	protected int testPort;
84  
85  	protected File knownHosts;
86  
87  	private File homeDir;
88  
89  	@Override
90  	public void setUp() throws Exception {
91  		super.setUp();
92  		writeTrashFile("file.txt", "something");
93  		try (Git git = new Git(db)) {
94  			git.add().addFilepattern("file.txt").call();
95  			git.commit().setMessage("Initial commit").call();
96  		}
97  		mockSystemReader.setProperty("user.home",
98  				getTemporaryDirectory().getAbsolutePath());
99  		mockSystemReader.setProperty("HOME",
100 				getTemporaryDirectory().getAbsolutePath());
101 		homeDir = FS.DETECTED.userHome();
102 		FS.DETECTED.setUserHome(getTemporaryDirectory().getAbsoluteFile());
103 		sshDir = new File(getTemporaryDirectory(), ".ssh");
104 		assertTrue(sshDir.mkdir());
105 		File serverDir = new File(getTemporaryDirectory(), "srv");
106 		assertTrue(serverDir.mkdir());
107 		// Create two key pairs. Let's not call them "id_rsa".
108 		KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
109 		generator.initialize(2048);
110 		privateKey1 = new File(sshDir, "first_key");
111 		privateKey2 = new File(sshDir, "second_key");
112 		publicKey1 = createKeyPair(generator.generateKeyPair(), privateKey1);
113 		createKeyPair(generator.generateKeyPair(), privateKey2);
114 		// Create a host key
115 		KeyPair hostKey = generator.generateKeyPair();
116 		// Start a server with our test user and the first key.
117 		server = new SshTestGitServer(TEST_USER, publicKey1.toPath(), db,
118 				hostKey);
119 		testPort = server.start();
120 		assertTrue(testPort > 0);
121 		knownHosts = new File(sshDir, "known_hosts");
122 		StringBuilder knownHostsLine = new StringBuilder();
123 		knownHostsLine.append("[localhost]:").append(testPort).append(' ');
124 		PublicKeyEntry.appendPublicKeyEntry(knownHostsLine,
125 				hostKey.getPublic());
126 		Files.write(knownHosts.toPath(),
127 				Collections.singleton(knownHostsLine.toString()));
128 		factory = createSessionFactory();
129 		SshSessionFactory.setInstance(factory);
130 	}
131 
132 	private static File createKeyPair(KeyPair newKey, File privateKeyFile)
133 			throws Exception {
134 		// Write PKCS#8 PEM unencrypted. Both JSch and sshd can read that.
135 		PrivateKey privateKey = newKey.getPrivate();
136 		String format = privateKey.getFormat();
137 		if (!"PKCS#8".equalsIgnoreCase(format)) {
138 			throw new IOException("Cannot write " + privateKey.getAlgorithm()
139 					+ " key in " + format + " format");
140 		}
141 		try (BufferedWriter writer = Files.newBufferedWriter(
142 				privateKeyFile.toPath(), StandardCharsets.US_ASCII)) {
143 			writer.write("-----BEGIN PRIVATE KEY-----");
144 			writer.newLine();
145 			write(writer, privateKey.getEncoded(), 64);
146 			writer.write("-----END PRIVATE KEY-----");
147 			writer.newLine();
148 		}
149 		File publicKeyFile = new File(privateKeyFile.getParentFile(),
150 				privateKeyFile.getName() + ".pub");
151 		StringBuilder builder = new StringBuilder();
152 		PublicKeyEntry.appendPublicKeyEntry(builder, newKey.getPublic());
153 		builder.append(' ').append(TEST_USER);
154 		try (OutputStream out = new FileOutputStream(publicKeyFile)) {
155 			out.write(builder.toString().getBytes(StandardCharsets.US_ASCII));
156 		}
157 		return publicKeyFile;
158 	}
159 
160 	private static void write(BufferedWriter out, byte[] bytes, int lineLength)
161 			throws IOException {
162 		String data = Base64.getEncoder().encodeToString(bytes);
163 		int last = data.length();
164 		for (int i = 0; i < last; i += lineLength) {
165 			if (i + lineLength <= last) {
166 				out.write(data.substring(i, i + lineLength));
167 			} else {
168 				out.write(data.substring(i));
169 			}
170 			out.newLine();
171 		}
172 		Arrays.fill(bytes, (byte) 0);
173 	}
174 
175 	/**
176 	 * Creates a new known_hosts file with one entry for the given host and port
177 	 * taken from the given public key file.
178 	 *
179 	 * @param file
180 	 *            to write the known_hosts file to
181 	 * @param host
182 	 *            for the entry
183 	 * @param port
184 	 *            for the entry
185 	 * @param publicKey
186 	 *            to use
187 	 * @return the public-key part of the line
188 	 * @throws IOException
189 	 */
190 	protected static String createKnownHostsFile(File file, String host,
191 			int port, File publicKey) throws IOException {
192 		List<String> lines = Files.readAllLines(publicKey.toPath(),
193 				StandardCharsets.UTF_8);
194 		assertEquals("Public key has too many lines", 1, lines.size());
195 		String pubKey = lines.get(0);
196 		// Strip off the comment.
197 		String[] parts = pubKey.split("\\s+");
198 		assertTrue("Unexpected key content",
199 				parts.length == 2 || parts.length == 3);
200 		String keyPart = parts[0] + ' ' + parts[1];
201 		String line = '[' + host + "]:" + port + ' ' + keyPart;
202 		Files.write(file.toPath(), Collections.singletonList(line));
203 		return keyPart;
204 	}
205 
206 	/**
207 	 * Checks whether there is a line for the given host and port that also
208 	 * matches the given key part in the list of lines.
209 	 *
210 	 * @param host
211 	 *            to look for
212 	 * @param port
213 	 *            to look for
214 	 * @param keyPart
215 	 *            to look for
216 	 * @param lines
217 	 *            to look in
218 	 * @return {@code true} if found, {@code false} otherwise
219 	 */
220 	protected boolean hasHostKey(String host, int port, String keyPart,
221 			List<String> lines) {
222 		String h = '[' + host + "]:" + port;
223 		return lines.stream()
224 				.anyMatch(l -> l.contains(h) && l.contains(keyPart));
225 	}
226 
227 	@After
228 	public void shutdownServer() throws Exception {
229 		if (server != null) {
230 			server.stop();
231 			server = null;
232 		}
233 		FS.DETECTED.setUserHome(homeDir);
234 		SshSessionFactory.setInstance(null);
235 		factory = null;
236 	}
237 
238 	protected abstract SshSessionFactory createSessionFactory();
239 
240 	protected SshSessionFactory getSessionFactory() {
241 		return factory;
242 	}
243 
244 	protected abstract void installConfig(String... config);
245 
246 	/**
247 	 * Copies a test data file contained in the test bundle to the given file.
248 	 * Equivalent to {@link #copyTestResource(Class, String, File)} with
249 	 * {@code SshTestHarness.class} as first parameter.
250 	 *
251 	 * @param resourceName
252 	 *            of the test resource to copy
253 	 * @param to
254 	 *            file to copy the resource to
255 	 * @throws IOException
256 	 *             if the resource cannot be copied
257 	 */
258 	protected void copyTestResource(String resourceName, File to)
259 			throws IOException {
260 		copyTestResource(SshTestHarness.class, resourceName, to);
261 	}
262 
263 	/**
264 	 * Copies a test data file contained in the test bundle to the given file,
265 	 * using {@link Class#getResourceAsStream(String)} to get the test resource.
266 	 *
267 	 * @param loader
268 	 *            {@link Class} to use to load the resource
269 	 * @param resourceName
270 	 *            of the test resource to copy
271 	 * @param to
272 	 *            file to copy the resource to
273 	 * @throws IOException
274 	 *             if the resource cannot be copied
275 	 */
276 	protected void copyTestResource(Class<?> loader, String resourceName,
277 			File to) throws IOException {
278 		try (InputStream in = loader.getResourceAsStream(resourceName)) {
279 			Files.copy(in, to.toPath());
280 		}
281 	}
282 
283 	protected File cloneWith(String uri, File to, CredentialsProvider provider,
284 			String... config) throws Exception {
285 		installConfig(config);
286 		CloneCommand clone = Git.cloneRepository().setCloneAllBranches(true)
287 				.setDirectory(to).setURI(uri);
288 		if (provider != null) {
289 			clone.setCredentialsProvider(provider);
290 		}
291 		try (Git git = clone.call()) {
292 			Repository repo = git.getRepository();
293 			assertNotNull(repo.resolve("master"));
294 			assertNotEquals(db.getWorkTree(),
295 					git.getRepository().getWorkTree());
296 			assertTrue(new File(git.getRepository().getWorkTree(), "file.txt")
297 					.exists());
298 			return repo.getWorkTree();
299 		}
300 	}
301 
302 	protected void pushTo(File localClone) throws Exception {
303 		pushTo(null, localClone);
304 	}
305 
306 	protected void pushTo(CredentialsProvider provider, File localClone)
307 			throws Exception {
308 		RevCommit commit;
309 		File newFile = null;
310 		try (Git git = Git.open(localClone)) {
311 			// Write a new file and modify a file.
312 			Repository local = git.getRepository();
313 			newFile = File.createTempFile("new", "sshtest",
314 					local.getWorkTree());
315 			write(newFile, "something new");
316 			File existingFile = new File(local.getWorkTree(), "file.txt");
317 			write(existingFile, "something else");
318 			git.add().addFilepattern("file.txt")
319 					.addFilepattern(newFile.getName())
320 					.call();
321 			commit = git.commit().setMessage("Local commit").call();
322 			// Push
323 			PushCommand push = git.push().setPushAll();
324 			if (provider != null) {
325 				push.setCredentialsProvider(provider);
326 			}
327 			Iterable<PushResult> results = push.call();
328 			for (PushResult result : results) {
329 				for (RemoteRefUpdate u : result.getRemoteUpdates()) {
330 					assertEquals(
331 							"Could not update " + u.getRemoteName() + ' '
332 									+ u.getMessage(),
333 							RemoteRefUpdate.Status.OK, u.getStatus());
334 				}
335 			}
336 		}
337 		// Now check "master" in the remote repo directly:
338 		assertEquals("Unexpected remote commit", commit, db.resolve("master"));
339 		assertEquals("Unexpected remote commit", commit,
340 				db.resolve(Constants.HEAD));
341 		File remoteFile = new File(db.getWorkTree(), newFile.getName());
342 		assertFalse("File should not exist on remote", remoteFile.exists());
343 		try (Git git = new Git(db)) {
344 			git.reset().setMode(ResetType.HARD).setRef(Constants.HEAD).call();
345 		}
346 		assertTrue("File does not exist on remote", remoteFile.exists());
347 		checkFile(remoteFile, "something new");
348 	}
349 
350 	protected static class TestCredentialsProvider extends CredentialsProvider {
351 
352 		private final List<String> stringStore;
353 
354 		private final Iterator<String> strings;
355 
356 		public TestCredentialsProvider(String... strings) {
357 			if (strings == null || strings.length == 0) {
358 				stringStore = Collections.emptyList();
359 			} else {
360 				stringStore = Arrays.asList(strings);
361 			}
362 			this.strings = stringStore.iterator();
363 		}
364 
365 		@Override
366 		public boolean isInteractive() {
367 			return true;
368 		}
369 
370 		@Override
371 		public boolean supports(CredentialItem... items) {
372 			return true;
373 		}
374 
375 		@Override
376 		public boolean get(URIish uri, CredentialItem... items)
377 				throws UnsupportedCredentialItem {
378 			System.out.println("URI: " + uri);
379 			for (CredentialItem item : items) {
380 				System.out.println(item.getClass().getSimpleName() + ' '
381 						+ item.getPromptText());
382 			}
383 			logItems(uri, items);
384 			for (CredentialItem item : items) {
385 				if (item instanceof CredentialItem.InformationalMessage) {
386 					continue;
387 				}
388 				if (item instanceof CredentialItem.YesNoType) {
389 					((CredentialItem.YesNoType) item).setValue(true);
390 				} else if (item instanceof CredentialItem.CharArrayType) {
391 					if (strings.hasNext()) {
392 						((CredentialItem.CharArrayType) item)
393 								.setValue(strings.next().toCharArray());
394 					} else {
395 						return false;
396 					}
397 				} else if (item instanceof CredentialItem.StringType) {
398 					if (strings.hasNext()) {
399 						((CredentialItem.StringType) item)
400 								.setValue(strings.next());
401 					} else {
402 						return false;
403 					}
404 				} else {
405 					return false;
406 				}
407 			}
408 			return true;
409 		}
410 
411 		private List<LogEntry> log = new ArrayList<>();
412 
413 		private void logItems(URIish uri, CredentialItem... items) {
414 			log.add(new LogEntry(uri, Arrays.asList(items)));
415 		}
416 
417 		public List<LogEntry> getLog() {
418 			return log;
419 		}
420 	}
421 
422 	protected static class LogEntry {
423 
424 		private URIish uri;
425 
426 		private List<CredentialItem> items;
427 
428 		public LogEntry(URIish uri, List<CredentialItem> items) {
429 			this.uri = uri;
430 			this.items = items;
431 		}
432 
433 		public URIish getURIish() {
434 			return uri;
435 		}
436 
437 		public List<CredentialItem> getItems() {
438 			return items;
439 		}
440 	}
441 }