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