/*
* Copyright 2012 aquenos GmbH.
* All rights reserved.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v1.0 which accompanies this distribution,
* and is available at http://www.eclipse.org/legal/epl-v10.html.
*/
package com.aquenos.scm.ssh.git;
import com.aquenos.scm.ssh.server.AbstractCommand;
import com.aquenos.scm.ssh.server.ScmSshServer;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject;
import org.apache.sshd.server.Command;
import org.apache.sshd.server.CommandFactory;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.RepositoryCache;
import org.eclipse.jgit.lib.RepositoryCache.FileKey;
import org.eclipse.jgit.transport.ReceivePack;
import org.eclipse.jgit.transport.UploadPack;
import org.eclipse.jgit.util.FS;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.PermissionType;
import sonia.scm.repository.PermissionUtil;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.spi.HookEventFacade;
import sonia.scm.user.User;
import sonia.scm.web.GitReceiveHook;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.Callable;
/**
* Factory that creates SSH commands on the server-side for handling Git
* commands from remote hosts.
*
* @author Sebastian Marsching
* @author Sergi Baila
*/
@Singleton
public class GitCommandFactory implements CommandFactory {
private GitRepositoryHandler repositoryHandler;
private RepositoryManager repositoryManager;
private ScmConfiguration configuration;
private GitReceiveHook hook;
/**
* Constructor. Meant to be called by Guice.
* @param repositoryHandler
* repository handler for Git repositories.
* @param repositoryManager
* SCM repository manager.
* @param configuration
* @param hookEventFacade
*/
@Inject
public GitCommandFactory(GitRepositoryHandler repositoryHandler,
RepositoryManager repositoryManager,
ScmConfiguration configuration,
HookEventFacade hookEventFacade) {
this.repositoryHandler = repositoryHandler;
this.repositoryManager = repositoryManager;
this.configuration = configuration;
this.hook = new GitReceiveHook(hookEventFacade, repositoryHandler);
}
@Override
public Command createCommand(final String commandString) {
List<String> commandParts = parseCommandLine(commandString);
if (commandParts == null) {
return invalidCommandLine();
} else if (commandParts.size() < 2) {
return unsupportedCommand();
} else if (commandParts.get(0).equals("git")) {
if (commandParts.get(1).equals("upload-pack")
|| commandParts.get(1).equals("receive-pack")) {
commandParts.remove(0);
commandParts.set(0, "git-" + commandParts.get(0));
} else {
return unsupportedCommand();
}
}
String directory = null;
boolean strictMode = false;
int timeout = 0;
boolean gitUploadPack = false;
boolean gitReceivePack = false;
if (commandParts.get(0).equals("git-upload-pack")) {
gitUploadPack = true;
} else if (commandParts.get(0).equals("git-receive-pack")) {
gitReceivePack = true;
} else {
return unsupportedCommand();
}
for (int i = 1; i < commandParts.size(); i++) {
if (gitUploadPack && commandParts.get(i).equals("--strict")) {
strictMode = true;
continue;
} else if (gitUploadPack
&& commandParts.get(i).startsWith("--timeout=")) {
String timeoutString = commandParts.get(i).substring(
"--timeout=".length());
try {
timeout = Integer.parseInt(timeoutString);
} catch (NumberFormatException e) {
return unsupportedParameter();
}
if (timeout < 0) {
return unsupportedParameter();
}
continue;
} else {
if (directory != null) {
return unsupportedParameter();
} else {
directory = commandParts.get(i);
}
}
}
if (directory == null) {
return unsupportedCommand();
}
// Verify that directory is a valid path, without path traversal.
// Backslashes will be converted to forward slashes (or vice-versa)
// by the File class, so we convert to forward slashes first and then
// check the path.
directory = directory.replace('\\', '/');
if (directory.startsWith("../") || directory.endsWith("/..")
|| directory.contains("/../")) {
return unsupportedParameter();
}
if (gitUploadPack) {
return new GitUploadPackCommand(directory, strictMode, timeout);
} else if (gitReceivePack) {
return new GitReceivePackCommand(directory);
} else {
return unsupportedCommand();
}
}
private static Command invalidCommandLine() {
return new AbstractCommand() {
@Override
protected int run() {
return errorMessage(-2, "Invalid command line.");
}
};
}
private static Command unsupportedCommand() {
return new AbstractCommand() {
@Override
protected int run() {
return errorMessage(-2, "Not a supported Git command.");
}
};
}
private static Command unsupportedParameter() {
return new AbstractCommand() {
@Override
protected int run() {
return errorMessage(-3,
"Unsupported parameter specified for Git command.");
}
};
}
private static List<String> parseCommandLine(String commandLine) {
// Parse the command line in a similar way like a shell would.
// However, we simplify things by not supporting variables and
// only allowing a single command. Any command issued by a
// valid Git client will match these criteria anyway.
StringReader reader = new StringReader(commandLine);
LinkedList<String> args = new LinkedList<String>();
try {
int c;
boolean inSingleQuotes = false;
boolean inDoubleQuotes = false;
boolean inEscapeSequence = false;
StringBuilder sb = new StringBuilder();
do {
c = reader.read();
if (c == 0) {
// Unexpected null-byte. Probably someone is trying
// something nasty.
return null;
} else if (c == -1) {
// End of stream.
break;
}
if (inSingleQuotes || inDoubleQuotes) {
if (inEscapeSequence) {
if ((c == '\'' && inSingleQuotes)
|| (c == '"' && inDoubleQuotes) || c == '\\') {
sb.append((char) c);
} else {
sb.append('\\');
sb.append((char) c);
}
inEscapeSequence = false;
} else {
if (c == '\'' && inSingleQuotes) {
inSingleQuotes = false;
} else if (c == '"' && inDoubleQuotes) {
inDoubleQuotes = false;
} else if (c == '\\') {
inEscapeSequence = true;
} else {
sb.append((char) c);
}
}
} else {
if (inEscapeSequence) {
if (Character.isWhitespace((char) c) || c == '\\'
|| c == '\'' || c == '"' || c == ';'
|| c == '&' || c == '|' || c == '>' || c == '<'
|| c == '(' || c == ')' || c == '`' || c == '{'
|| c == '}' || c == '!' || c == '*' || c == '#') {
sb.append((char) c);
} else {
sb.append('\\');
sb.append((char) c);
}
inEscapeSequence = false;
} else {
if (c == '\\') {
inEscapeSequence = true;
} else if (c == '\'') {
inSingleQuotes = true;
} else if (c == '"') {
inDoubleQuotes = true;
} else if (c == ';' || c == '&' || c == '|' || c == '>'
|| c == '<' || c == '(' || c == ')' || c == '`'
|| c == '{' || c == '}' || c == '\n'
|| c == '\r') {
// These characters have special meanings in a
// shell, but we do not support them.
return null;
} else if (Character.isWhitespace((char) c)) {
// Next argument.
if (sb.length() > 0) {
args.add(sb.toString());
sb.setLength(0);
}
} else if (c == '#') {
// Treat this like an end of line.
break;
} else {
sb.append((char) c);
}
}
}
} while (c != -1);
if (inSingleQuotes || inDoubleQuotes) {
// Unfinished quotes.
return null;
}
if (sb.length() > 0) {
args.add(sb.toString());
sb.setLength(0);
}
return args;
} catch (IOException e) {
throw new RuntimeException("Unexpected IOException.", e);
}
}
private abstract class AbstractGitCommand extends AbstractCommand {
protected String directory;
private boolean strictMode;
protected Repository gitRepository;
protected String username;
protected String remoteHost;
public AbstractGitCommand(String directory, boolean strictMode) {
this.directory = directory;
this.strictMode = strictMode;
}
@Override
protected int run() {
// Get subject from session and set it for this thread.
Subject subject = getSession().getAttribute(
ScmSshServer.SUBJECT_SESSION_ATTRIBUTE_KEY);
if (subject == null) {
return errorMessage(-6, "Internal error");
}
try {
return subject.associateWith(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return runWithSubject();
}
}).call();
} catch (Exception e) {
throw new RuntimeException(
"Error while trying to execute Git command: "
+ e.getMessage(), e);
}
}
private int runWithSubject() {
this.username = SecurityUtils.getSubject().getPrincipals()
.oneByType(User.class).getId();
SocketAddress remoteSocketAddress = getSession().getIoSession()
.getRemoteAddress();
if (remoteSocketAddress instanceof InetSocketAddress) {
this.remoteHost = ((InetSocketAddress) remoteSocketAddress)
.getHostName();
} else {
this.remoteHost = "unknown";
}
// Check path for path traversal has already been done by
// GitCommandFactory, so we can just create the File instance.
String directoryPath = this.directory;
if (directoryPath.startsWith("/")) {
directoryPath = directoryPath.substring(1);
}
String type = repositoryHandler.getType().getName();
if (!directoryPath.startsWith(type + "/")) {
return errorMessage(-4,
"The requested repository does not exist.");
}
directoryPath = directoryPath.substring(type.length() + 1);
File repositoryDir = new File(repositoryHandler.getConfig()
.getRepositoryDirectory(), directoryPath);
sonia.scm.repository.Repository scmRepository = repositoryManager
.getFromUri(directory);
if (scmRepository == null || !scmRepository.getType().equals("git")) {
return errorMessage(-4,
"The requested repository does not exist.");
}
try {
FileKey key;
if (strictMode) {
key = FileKey.exact(repositoryDir, FS.DETECTED);
} else {
key = FileKey.lenient(repositoryDir, FS.DETECTED);
}
gitRepository = RepositoryCache.open(key, true);
} catch (RepositoryNotFoundException e) {
return errorMessage(-4,
"The requested repository does not exist.");
} catch (IOException e) {
return errorMessage(-4,
"Error while trying to open requested repository.");
}
boolean permitted;
if (isWriteCommand()) {
permitted = PermissionUtil.isWritable(configuration,
scmRepository);
} else {
permitted = PermissionUtil.hasPermission(configuration,
scmRepository, PermissionType.READ);
}
if (!permitted) {
return errorMessage(-5, "Permission denied.");
}
// Repository request listeners are tied to HTTP request and
// response, thus we cannot call them here.
return runGitCommand();
}
protected abstract int runGitCommand();
protected abstract boolean isWriteCommand();
}
private class GitReceivePackCommand extends AbstractGitCommand {
public GitReceivePackCommand(String directory) {
super(directory, true);
}
@Override
protected int runGitCommand() {
ReceivePack receivePack = new ReceivePack(gitRepository);
receivePack.setPreReceiveHook(hook);
receivePack.setPostReceiveHook(hook);
receivePack.setRefLogIdent(new PersonIdent(username, username + "@"
+ remoteHost));
try {
receivePack.receive(getInputStream(), getOutputStream(),
getErrorStream());
} catch (IOException e) {
return -4;
}
return 0;
}
@Override
protected boolean isWriteCommand() {
return true;
}
}
private class GitUploadPackCommand extends AbstractGitCommand {
private int timeout;
public GitUploadPackCommand(String directory, boolean strictMode,
int timeout) {
super(directory, strictMode);
this.timeout = timeout;
}
@Override
protected int runGitCommand() {
UploadPack uploadPack = new UploadPack(gitRepository);
uploadPack.setTimeout(timeout);
try {
uploadPack.upload(getInputStream(), getOutputStream(),
getErrorStream());
} catch (IOException e) {
return -4;
}
return 0;
}
@Override
protected boolean isWriteCommand() {
return false;
}
}
}