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