blob: 75ff08525af4a328707b4298dd0e9625566136c7 [file] [log] [blame]
/*
* Copyright 2000-2013 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jetbrains.idea.svn.commandLine;
import com.intellij.execution.process.ProcessOutputTypes;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.ApplicationNamesInfo;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.openapi.vcs.LineHandlerHelper;
import com.intellij.openapi.vcs.LineProcessEventListener;
import com.intellij.openapi.vcs.VcsException;
import com.intellij.util.ArrayUtil;
import com.intellij.util.EventDispatcher;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.idea.svn.SvnApplicationSettings;
import org.jetbrains.idea.svn.SvnUtil;
import org.tmatesoft.svn.core.SVNException;
import org.tmatesoft.svn.core.SVNURL;
import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager;
import org.tmatesoft.svn.core.auth.SVNAuthentication;
import org.tmatesoft.svn.core.auth.SVNPasswordAuthentication;
import org.tmatesoft.svn.core.auth.SVNSSLAuthentication;
import java.io.File;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Created with IntelliJ IDEA.
* User: Irina.Chernushina
* Date: 1/25/12
* Time: 4:05 PM
*
* honestly stolen from GitLineHandler
*/
public class SvnLineCommand extends SvnCommand {
public static final String AUTHENTICATION_REALM = "Authentication realm:";
public static final String CERTIFICATE_ERROR = "Error validating server certificate for";
public static final String PASSPHRASE_FOR = "Passphrase for";
public static final String UNABLE_TO_CONNECT_CODE = "svn: E170001:";
public static final String UNABLE_TO_CONNECT_MESSAGE = "Unable to connect to a repository";
public static final String CANNOT_AUTHENTICATE_TO_PROXY = "Could not authenticate to proxy server";
public static final String AUTHENTICATION_FAILED_MESSAGE = "Authentication failed";
private static final String INVALID_CREDENTIALS_FOR_SVN_PROTOCOL = "svn: E170001: Can't get";
private static final String UNTRUSTED_SERVER_CERTIFICATE = "Server SSL certificate untrusted";
private static final String ACCESS_TO_PREFIX = "Access to ";
private static final String FORBIDDEN_STATUS = "forbidden";
private static final String PASSWORD_STRING = "password";
private static final Pattern UNABLE_TO_CONNECT_TO_URL_PATTERN = Pattern.compile("Unable to connect to a repository at URL '(.*)'");
// kept for exact text
//public static final String CLIENT_CERTIFICATE_FILENAME = "Client certificate filename:";
/**
* the partial line from stdout stream
*/
private final StringBuilder myStdoutLine = new StringBuilder();
/**
* the partial line from stderr stream
*/
private final StringBuilder myStderrLine = new StringBuilder();
private final EventDispatcher<LineProcessEventListener> myLineListeners;
private final AtomicReference<Integer> myExitCode;
private final StringBuffer myErr;
private final StringBuffer myStdOut;
public SvnLineCommand(File workingDirectory, @NotNull SvnCommandName commandName, @NotNull @NonNls String exePath) {
this(workingDirectory, commandName, exePath, null);
}
public SvnLineCommand(File workingDirectory, @NotNull SvnCommandName commandName, @NotNull @NonNls String exePath, File configDir) {
super(workingDirectory, commandName, exePath, configDir);
myLineListeners = EventDispatcher.create(LineProcessEventListener.class);
myExitCode = new AtomicReference<Integer>();
myErr = new StringBuffer();
myStdOut = new StringBuffer();
}
@Override
protected void processTerminated(int exitCode) {
// force newline
if (myStdoutLine.length() != 0) {
onTextAvailable("\n\r", ProcessOutputTypes.STDOUT);
}
else if (myStderrLine.length() != 0) {
onTextAvailable("\n\r", ProcessOutputTypes.STDERR);
}
}
public static SvnLineCommand runWithAuthenticationAttempt(@NotNull final File workingDirectory,
@Nullable final SVNURL repositoryUrl,
SvnCommandName commandName,
final LineCommandListener listener,
@NotNull AuthenticationCallback authenticationCallback,
String... parameters) throws SvnBindException {
File configDir = null;
try {
// for IDEA proxy case
writeIdeaConfig2SubversionConfig(authenticationCallback, repositoryUrl);
configDir = authenticationCallback.getSpecialConfigDir();
while (true) {
final String exePath = SvnApplicationSettings.getInstance().getCommandLinePath();
final SvnLineCommand command = runCommand(exePath, commandName, listener, workingDirectory, configDir, parameters);
final Integer exitCode = command.myExitCode.get();
// could be situations when exit code = 0, but there is info "warning" in error stream for instance, for "svn status"
// on non-working copy folder
// TODO: synchronization does not work well in all cases - sometimes exit code is not yet set and null returned - fix synchronization
// here we treat null exit code as some non-zero exit code
if (exitCode == null || exitCode != 0) {
logNullExitCode(command, exitCode);
if (command.myErr.length() > 0) {
// handle authentication
final String errText = command.myErr.toString().trim();
final AuthCallbackCase callback = createCallback(errText, authenticationCallback, repositoryUrl);
if (callback != null) {
cleanup(exePath, command, workingDirectory);
if (callback.getCredentials(errText)) {
if (authenticationCallback.getSpecialConfigDir() != null) {
configDir = authenticationCallback.getSpecialConfigDir();
}
parameters = updateParameters(callback, parameters);
continue;
}
}
throw new SvnBindException(errText);
} else {
// no errors found in error stream => we treat null exitCode as successful, otherwise exception is thrown
if (exitCode != null) {
// here exitCode != null && exitCode != 0
throw new SvnBindException("Svn process exited with error code: " + exitCode);
}
}
} else if (command.myErr.length() > 0) {
// here exitCode == 0, but some warnings are in error stream
LOG.info("Detected warning - " + command.myErr);
}
return command;
}
} finally {
if (authenticationCallback != null) {
authenticationCallback.reset();
}
}
}
private static void logNullExitCode(@NotNull SvnLineCommand command, @Nullable Integer exitCode) {
if (exitCode == null) {
LOG.info("Null exit code returned, but not errors detected " + command.getCommandText());
}
}
private static String[] updateParameters(AuthCallbackCase callback, String[] parameters) {
List<String> p = new ArrayList<String>(Arrays.asList(parameters));
callback.updateParameters(p);
return ArrayUtil.toStringArray(p);
}
private static void writeIdeaConfig2SubversionConfig(@NotNull AuthenticationCallback authenticationCallback,
@Nullable SVNURL repositoryUrl) throws SvnBindException {
if (authenticationCallback.haveDataForTmpConfig()) {
try {
if (!authenticationCallback.persistDataToTmpConfig(repositoryUrl)) {
throw new SvnBindException("Can not persist " + ApplicationNamesInfo.getInstance().getProductName() +
" HTTP proxy information into tmp config directory");
}
}
catch (IOException e) {
throw new SvnBindException(e);
}
catch (URISyntaxException e) {
throw new SvnBindException(e);
}
assert authenticationCallback.getSpecialConfigDir() != null;
}
}
private static AuthCallbackCase createCallback(final String errText,
@NotNull final AuthenticationCallback callback,
final SVNURL url) {
if (errText.startsWith(CERTIFICATE_ERROR)) {
return new CertificateCallbackCase(callback, url);
}
if (errText.startsWith(AUTHENTICATION_REALM)) {
return new CredentialsCallback(callback, url);
}
if (errText.startsWith(PASSPHRASE_FOR)) {
return new PassphraseCallback(callback, url);
}
if (errText.startsWith(UNABLE_TO_CONNECT_CODE) && errText.contains(CANNOT_AUTHENTICATE_TO_PROXY)) {
return new ProxyCallback(callback, url);
}
// http/https protocol invalid credentials
if (errText.contains(AUTHENTICATION_FAILED_MESSAGE)) {
return new UsernamePasswordCallback(callback, url);
}
// messages could be "Can't get password", "Can't get username or password"
if (errText.contains(INVALID_CREDENTIALS_FOR_SVN_PROTOCOL) && errText.contains(PASSWORD_STRING)) {
// svn protocol invalid credentials
return new UsernamePasswordCallback(callback, url);
}
// http/https protocol, svn 1.7, non-interactive
if (errText.contains(UNABLE_TO_CONNECT_MESSAGE)) {
return new UsernamePasswordCallback(callback, url);
}
// https one-way protocol untrusted server certificate
if (errText.contains(UNTRUSTED_SERVER_CERTIFICATE)) {
return new CertificateCallbackCase(callback, url);
}
// https two-way protocol invalid client certificate
if (errText.contains(ACCESS_TO_PREFIX) && errText.contains(FORBIDDEN_STATUS)) {
return new TwoWaySslCallback(callback, url);
}
return null;
}
// Special callback for svn 1.8 credentials request as --non-interactive does not return
// authentication realm (just url) - so we could not create temp cache
private static class UsernamePasswordCallback extends AuthCallbackCase {
protected SVNAuthentication myAuthentication;
protected UsernamePasswordCallback(@NotNull AuthenticationCallback callback, SVNURL url) {
super(callback, url);
}
@Override
boolean getCredentials(String errText) throws SvnBindException {
myAuthentication = myAuthenticationCallback.requestCredentials(myUrl != null ? myUrl : parseUrlFromError(errText),
getType());
return myAuthentication != null;
}
public String getType() {
return ISVNAuthenticationManager.PASSWORD;
}
@Override
public void updateParameters(List<String> parameters) {
if (myAuthentication instanceof SVNPasswordAuthentication) {
SVNPasswordAuthentication auth = (SVNPasswordAuthentication)myAuthentication;
parameters.add("--username");
parameters.add(auth.getUserName());
parameters.add("--password");
parameters.add(auth.getPassword());
if (!auth.isStorageAllowed()) {
parameters.add("--no-auth-cache");
}
}
}
private SVNURL parseUrlFromError(String errorText) {
Matcher matcher = UNABLE_TO_CONNECT_TO_URL_PATTERN.matcher(errorText);
String urlValue = null;
if (matcher.find()) {
urlValue = matcher.group(1);
}
return urlValue != null ? parseUrl(urlValue) : null;
}
private SVNURL parseUrl(String urlValue) {
try {
return SVNURL.parseURIEncoded(urlValue);
}
catch (SVNException e) {
return null;
}
}
}
private static class ProxyCallback extends AuthCallbackCase {
protected ProxyCallback(@NotNull AuthenticationCallback callback, SVNURL url) {
super(callback, url);
}
@Override
boolean getCredentials(String errText) throws SvnBindException {
return myAuthenticationCallback.askProxyCredentials(myUrl);
}
}
private static class CredentialsCallback extends AuthCallbackCase {
private CredentialsCallback(@NotNull AuthenticationCallback callback, SVNURL url) {
super(callback, url);
}
@Override
boolean getCredentials(String errText) throws SvnBindException {
final String realm =
errText.startsWith(AUTHENTICATION_REALM) ? cutFirstLine(errText).substring(AUTHENTICATION_REALM.length()).trim() : null;
final boolean isPassword = StringUtil.containsIgnoreCase(errText, "password");
if (myTried) {
myAuthenticationCallback.clearPassiveCredentials(realm, myUrl, isPassword);
}
myTried = true;
if (myAuthenticationCallback.authenticateFor(realm, myUrl, myAuthenticationCallback.getSpecialConfigDir() != null, isPassword)) {
return true;
}
throw new SvnBindException("Authentication canceled for realm: " + realm);
}
}
private static String cutFirstLine(final String text) {
final int idx = text.indexOf('\n');
if (idx == -1) return text;
return text.substring(0, idx);
}
private static class CertificateCallbackCase extends AuthCallbackCase {
private boolean accepted;
protected CertificateCallbackCase(@NotNull AuthenticationCallback callback, SVNURL url) {
super(callback, url);
}
@Override
public boolean getCredentials(final String errText) throws SvnBindException {
// parse realm from error text
String realm = errText;
final int idx1 = realm.indexOf('\'');
if (idx1 == -1) {
throw new SvnBindException("Can not detect authentication realm name: " + errText);
}
final int idx2 = realm.indexOf('\'', idx1 + 1);
if (idx2== -1) {
throw new SvnBindException("Can not detect authentication realm name: " + errText);
}
realm = realm.substring(idx1 + 1, idx2);
if (! myTried && myAuthenticationCallback.acceptSSLServerCertificate(myUrl, realm)) {
accepted = true;
myTried = true;
return true;
}
throw new SvnBindException("Server SSL certificate rejected");
}
@Override
public void updateParameters(List<String> parameters) {
if (accepted) {
parameters.add("--trust-server-cert");
// force --non-interactive as it is required by --trust-server-cert, but --non-interactive is not default mode for 1.7 or earlier
parameters.add("--non-interactive");
}
}
}
private static class TwoWaySslCallback extends UsernamePasswordCallback {
protected TwoWaySslCallback(AuthenticationCallback callback, SVNURL url) {
super(callback, url);
}
@Override
public String getType() {
return ISVNAuthenticationManager.SSL;
}
@Override
public void updateParameters(List<String> parameters) {
if (myAuthentication instanceof SVNSSLAuthentication) {
SVNSSLAuthentication auth = (SVNSSLAuthentication)myAuthentication;
// TODO: Seems that config option should be specified for concrete server and not for global group.
// as in that case it could be overriden by settings in config file
parameters.add("--config-option");
parameters.add("servers:global:ssl-client-cert-file=" + auth.getCertificatePath());
parameters.add("--config-option");
parameters.add("servers:global:ssl-client-cert-password=" + auth.getPassword());
if (!auth.isStorageAllowed()) {
parameters.add("--no-auth-cache");
}
}
}
}
private static abstract class AuthCallbackCase {
protected final SVNURL myUrl;
protected boolean myTried = false;
@NotNull protected final AuthenticationCallback myAuthenticationCallback;
protected AuthCallbackCase(@NotNull AuthenticationCallback callback, SVNURL url) {
myAuthenticationCallback = callback;
myUrl = url;
}
abstract boolean getCredentials(final String errText) throws SvnBindException;
public void updateParameters(List<String> parameters) {
}
}
private static void cleanup(String exePath, SvnCommand command, @NotNull File workingDirectory) throws SvnBindException {
if (command.isManuallyDestroyed() && command.getCommandName().isWriteable()) {
File wcRoot = SvnUtil.getWorkingCopyRootNew(workingDirectory);
// not all commands require cleanup - for instance, some commands operate only with repository - like "svn info <url>"
// TODO: check if we could "configure" commands (or make command to explicitly ask) if cleanup is required - not to search
// TODO: working copy root each time
if (wcRoot != null) {
runCommand(exePath, SvnCommandName.cleanup, new SvnCommitRunner.CommandListener(null), wcRoot, null);
} else {
LOG.info("Could not execute cleanup for command " + command.getCommandText());
}
}
}
/*svn: E170001: Commit failed (details follow):
svn: E170001: Unable to connect to a repository at URL 'htt../svn/secondRepo/local2/trunk/mod2/src/com/test/gggGA'
svn: E170001: OPTIONS of 'htt.../svn/secondRepo/local2/trunk/mod2/src/com/test/gggGA': authorization failed: Could not authenticate to server: rejected Basic challenge (ht)*/
private final static String ourAuthFailed = "authorization failed";
private final static String ourAuthFailed2 = "Could not authenticate to server";
private static boolean isAuthenticationFailed(String s) {
return s.trim().startsWith(AUTHENTICATION_REALM);
//return s.contains(ourAuthFailed) && s.contains(ourAuthFailed2);
}
private static SvnLineCommand runCommand(String exePath,
SvnCommandName commandName,
final LineCommandListener listener,
File base, File configDir,
String... parameters) throws SvnBindException {
final AtomicBoolean errorReceived = new AtomicBoolean(false);
final SvnLineCommand command = new SvnLineCommand(base, commandName, exePath, configDir) {
int myErrCnt = 0;
@Override
protected void onTextAvailable(String text, Key outputType) {
// we won't stop right now if got "authentication realm" -> since we want to get "password" string (that would mean password is expected
// or certificate maybe is expected
// but for certificate passphrase we get just "Passphrase for ..." - one line without line feed
// for client certificate (when no path in servers file) we get
// Authentication realm: <text>
// Client certificate filename:
if (ProcessOutputTypes.STDERR.equals(outputType)) {
++ myErrCnt;
final String trim = text.trim();
// should end in 1 second
errorReceived.set(true);
}
super.onTextAvailable(text, outputType);
}
};
command.addParameters(parameters);
command.addParameters("--non-interactive");
final AtomicReference<Throwable> exceptionRef = new AtomicReference<Throwable>();
// several threads
command.addLineListener(new LineProcessEventListener() {
@Override
public void onLineAvailable(String line, Key outputType) {
if (ProcessOutputTypes.STDOUT.equals(outputType)) {
command.myStdOut.append(line);
}
if (SvnCommand.LOG.isDebugEnabled()) {
SvnCommand.LOG.debug("==> " + line);
}
if (ApplicationManager.getApplication().isUnitTestMode()) {
System.out.println("==> " + line);
}
listener.onLineAvailable(line, outputType);
if (listener.isCanceled()) {
LOG.info("Cancelling command: " + command.getCommandText());
command.destroyProcess();
return;
}
if (ProcessOutputTypes.STDERR.equals(outputType)) {
if (command.myErr.length() > 0) {
command.myErr.append('\n');
}
command.myErr.append(line);
}
}
@Override
public void processTerminated(int exitCode) {
listener.processTerminated(exitCode);
command.myExitCode.set(exitCode);
}
@Override
public void startFailed(Throwable exception) {
listener.startFailed(exception);
exceptionRef.set(exception);
}
});
command.start();
boolean finished;
do {
finished = command.waitFor(500);
if (!finished && (errorReceived.get() || command.needsDestroy())) {
command.waitFor(1000);
command.doDestroyProcess();
break;
}
}
while (!finished);
if (exceptionRef.get() != null) {
throw new SvnBindException(exceptionRef.get());
}
return command;
}
@Override
protected void onTextAvailable(String text, Key outputType) {
Iterator<String> lines = LineHandlerHelper.splitText(text).iterator();
if (ProcessOutputTypes.STDOUT == outputType) {
notifyLines(outputType, lines, myStdoutLine);
}
else if (ProcessOutputTypes.STDERR == outputType) {
notifyLines(outputType, lines, myStderrLine);
}
}
private void notifyLines(final Key outputType, final Iterator<String> lines, final StringBuilder lineBuilder) {
if (!lines.hasNext()) return;
if (lineBuilder.length() > 0) {
lineBuilder.append(lines.next());
if (lines.hasNext()) {
// line is complete
final String line = lineBuilder.toString();
notifyLine(line, outputType);
lineBuilder.setLength(0);
}
}
while (true) {
String line = null;
if (lines.hasNext()) {
line = lines.next();
}
if (lines.hasNext()) {
notifyLine(line, outputType);
}
else {
if (line != null && line.length() > 0) {
lineBuilder.append(line);
}
break;
}
}
}
private void notifyLine(final String line, final Key outputType) {
String trimmed = LineHandlerHelper.trimLineSeparator(line);
myLineListeners.getMulticaster().onLineAvailable(trimmed, outputType);
}
public void addLineListener(LineProcessEventListener listener) {
myLineListeners.addListener(listener);
super.addListener(listener);
}
private static class PassphraseCallback extends AuthCallbackCase {
protected PassphraseCallback(@NotNull AuthenticationCallback callback, SVNURL url) {
super(callback, url);
}
@Override
boolean getCredentials(String errText) throws SvnBindException {
// try to get from file
/*if (myTried) {
myAuthenticationCallback.clearPassiveCredentials(null, myBase);
}*/
myTried = true;
if (myAuthenticationCallback.authenticateFor(null, myUrl, myAuthenticationCallback.getSpecialConfigDir() != null, false)) {
return true;
}
throw new SvnBindException("Authentication canceled for : " + errText.substring(PASSPHRASE_FOR.length()));
}
}
}