You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
441 lines
14 KiB
441 lines
14 KiB
/* |
|
* 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; |
|
|
|
protected SshTestGitServer server; |
|
|
|
private SshSessionFactory factory; |
|
|
|
protected int testPort; |
|
|
|
protected File knownHosts; |
|
|
|
private File homeDir; |
|
|
|
@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(); |
|
} |
|
mockSystemReader.setProperty("user.home", |
|
getTemporaryDirectory().getAbsolutePath()); |
|
mockSystemReader.setProperty("HOME", |
|
getTemporaryDirectory().getAbsolutePath()); |
|
homeDir = FS.DETECTED.userHome(); |
|
FS.DETECTED.setUserHome(getTemporaryDirectory().getAbsoluteFile()); |
|
sshDir = new File(getTemporaryDirectory(), ".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); |
|
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. Both JSch and 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 |
|
*/ |
|
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; |
|
} |
|
FS.DETECTED.setUserHome(homeDir); |
|
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; |
|
} |
|
} |
|
}
|
|
|