From: Duarte Meneses Date: Tue, 25 Aug 2020 19:25:04 +0000 (-0500) Subject: SONAR-13792 Embed sonar-scm-svn X-Git-Tag: 8.5.0.37579~110 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=da15a52568cbe80fe6ce70dd64aed3edd722af74;p=sonarqube.git SONAR-13792 Embed sonar-scm-svn --- diff --git a/build.gradle b/build.gradle index 9d5d86773f4..1211e587def 100644 --- a/build.gradle +++ b/build.gradle @@ -182,7 +182,6 @@ subprojects { dependency 'org.sonarsource.javascript:sonar-javascript-plugin:6.3.0.12464' // bundled_plugin:javascript:SonarJS dependency 'org.sonarsource.php:sonar-php-plugin:3.5.0.5655' // bundled_plugin:php:sonar-php dependency 'org.sonarsource.python:sonar-python-plugin:2.13.0.7236' // bundled_plugin:python:sonar-python - dependency 'org.sonarsource.scm.svn:sonar-scm-svn-plugin:1.10.0.1917' // bundled_plugin:scmsvn:sonar-scm-svn dependency 'org.sonarsource.slang:sonar-go-plugin:1.6.0.719' // bundled_plugin:go:slang-enterprise dependency 'org.sonarsource.slang:sonar-kotlin-plugin:1.5.0.315' // bundled_plugin:kotlin:slang-enterprise dependency 'org.sonarsource.slang:sonar-ruby-plugin:1.5.0.315' // bundled_plugin:ruby:slang-enterprise @@ -317,6 +316,7 @@ subprojects { dependency 'org.elasticsearch:mocksocket:1.0' dependency 'org.codelibs.elasticsearch.module:analysis-common:6.8.4' dependency 'org.eclipse.jgit:org.eclipse.jgit:5.7.0.202003110725-r' + dependency 'org.tmatesoft.svnkit:svnkit:1.10.1' dependency 'org.hamcrest:hamcrest-all:1.3' dependency 'org.jsoup:jsoup:1.13.1' dependency 'org.mindrot:jbcrypt:0.4' diff --git a/sonar-application/bundled_plugins.gradle b/sonar-application/bundled_plugins.gradle index 934e4c07e6d..5bcbf92af7f 100644 --- a/sonar-application/bundled_plugins.gradle +++ b/sonar-application/bundled_plugins.gradle @@ -12,7 +12,6 @@ dependencies { bundledPlugin 'org.sonarsource.slang:sonar-go-plugin@jar' bundledPlugin "org.sonarsource.slang:sonar-kotlin-plugin@jar" bundledPlugin "org.sonarsource.slang:sonar-ruby-plugin@jar" - bundledPlugin 'org.sonarsource.scm.svn:sonar-scm-svn-plugin@jar' bundledPlugin "org.sonarsource.slang:sonar-scala-plugin@jar" bundledPlugin 'org.sonarsource.xml:sonar-xml-plugin@jar' } diff --git a/sonar-scanner-engine/build.gradle b/sonar-scanner-engine/build.gradle index a555eb97561..45cc8d25ecd 100644 --- a/sonar-scanner-engine/build.gradle +++ b/sonar-scanner-engine/build.gradle @@ -29,6 +29,7 @@ dependencies { compile 'org.codehaus.woodstox:stax2-api' compile 'org.codehaus.woodstox:woodstox-core-lgpl' compile 'org.eclipse.jgit:org.eclipse.jgit' + compile 'org.tmatesoft.svnkit:svnkit' compile 'org.picocontainer:picocontainer' compile 'org.slf4j:jcl-over-slf4j' compile 'org.slf4j:jul-to-slf4j' diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scanner/scan/ProjectScanContainer.java b/sonar-scanner-engine/src/main/java/org/sonar/scanner/scan/ProjectScanContainer.java index ce29d00a358..30aac02d9f1 100644 --- a/sonar-scanner-engine/src/main/java/org/sonar/scanner/scan/ProjectScanContainer.java +++ b/sonar-scanner-engine/src/main/java/org/sonar/scanner/scan/ProjectScanContainer.java @@ -132,6 +132,7 @@ import org.sonar.scanner.sensor.ProjectSensorExtensionDictionnary; import org.sonar.scanner.sensor.ProjectSensorOptimizer; import org.sonar.scanner.sensor.ProjectSensorsExecutor; import org.sonar.scm.git.GitScmSupport; +import org.sonar.scm.svn.SvnScmSupport; import static org.sonar.api.batch.InstantiationStrategy.PER_BATCH; import static org.sonar.core.extension.CoreExtensionsInstaller.noExtensionFilter; @@ -302,7 +303,8 @@ public class ProjectScanContainer extends ComponentContainer { AnalysisObservers.class); - add(GitScmSupport.getClasses()); + add(GitScmSupport.getObjects()); + add(SvnScmSupport.getObjects()); addIfMissing(DefaultProjectSettingsLoader.class, ProjectSettingsLoader.class); addIfMissing(DefaultRulesLoader.class, RulesLoader.class); diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java index b5a845edc9a..2e0928d275f 100644 --- a/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/git/GitScmSupport.java @@ -24,7 +24,11 @@ import java.util.List; import org.eclipse.jgit.util.FS; public final class GitScmSupport { - public static List> getClasses() { + private GitScmSupport() { + // static only + } + + public static List getObjects() { FS.FileStoreAttributes.setBackground(true); return Arrays.asList( JGitBlameCommand.class, diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/AnnotationHandler.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/AnnotationHandler.java new file mode 100644 index 00000000000..81d0f007612 --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/AnnotationHandler.java @@ -0,0 +1,63 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.sonar.api.batch.scm.BlameLine; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.wc.ISVNAnnotateHandler; + +public class AnnotationHandler implements ISVNAnnotateHandler { + + private List lines = new ArrayList<>(); + + @Override + public void handleEOF() { + // Not used + } + + @Override + public void handleLine(Date date, long revision, String author, String line) throws SVNException { + // deprecated + } + + @Override + public void handleLine(Date date, long revision, String author, String line, Date mergedDate, + long mergedRevision, String mergedAuthor, String mergedPath, int lineNumber) throws SVNException { + lines.add(new BlameLine().date(mergedDate).revision(Long.toString(mergedRevision)).author(mergedAuthor)); + } + + @Override + public boolean handleRevision(Date date, long revision, String author, File contents) throws SVNException { + /* + * We do not want our file to be annotated for each revision of the range, but only for the last + * revision of it, so we return false + */ + return false; + } + + public List getLines() { + return lines; + } + +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ChangedLinesComputer.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ChangedLinesComputer.java new file mode 100644 index 00000000000..9a737e8f3da --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ChangedLinesComputer.java @@ -0,0 +1,163 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.OutputStream; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +class ChangedLinesComputer { + + private final Tracker tracker; + + private final OutputStream receiver = new OutputStream() { + StringBuilder sb = new StringBuilder(); + + @Override + public void write(int b) { + sb.append((char) b); + if (b == '\n') { + tracker.parseLine(sb.toString()); + sb.setLength(0); + } + } + }; + + ChangedLinesComputer(Path rootBaseDir, Set included) { + this.tracker = new Tracker(rootBaseDir, included); + } + + /** + * The OutputStream to pass to svnkit's diff command. + */ + OutputStream receiver() { + return receiver; + } + + /** + * From a stream of svn-style unified diff lines, + * compute the line numbers that should be considered changed. + * + * Example input: + *
+   * Index: path/to/file
+   * ===================================================================
+   * --- lao 2002-02-21 23:30:39.942229878 -0800
+   * +++ tzu 2002-02-21 23:30:50.442260588 -0800
+   * @@ -1,7 +1,6 @@
+   * -The Way that can be told of is not the eternal Way;
+   * -The name that can be named is not the eternal name.
+   *  The Nameless is the origin of Heaven and Earth;
+   * -The Named is the mother of all things.
+   * +The named is the mother of all things.
+   * +
+   *  Therefore let there always be non-being,
+   *    so we may see their subtlety,
+   *  And let there always be being,
+   * @@ -9,3 +8,6 @@
+   *  The two are the same,
+   *  But after they are produced,
+   *    they have different names.
+   * +They both may be called deep and profound.
+   * +Deeper and more profound,
+   * +The door of all subtleties!
+   * 
+ * + * See also: http://www.gnu.org/software/diffutils/manual/html_node/Example-Unified.html#Example-Unified + */ + Map> changedLines() { + return tracker.changedLines(); + } + + private static class Tracker { + + private static final Pattern START_LINE_IN_TARGET = Pattern.compile(" \\+(\\d+)"); + private static final String ENTRY_START_PREFIX = "Index: "; + + private final Map> changedLines = new HashMap<>(); + private final Set included; + private final Path rootBaseDir; + + private int lineNumInTarget; + private Path currentPath = null; + private int skipCount = 0; + + Tracker(Path rootBaseDir, Set included) { + this.rootBaseDir = rootBaseDir; + this.included = included; + } + + private void parseLine(String line) { + if (line.startsWith(ENTRY_START_PREFIX)) { + currentPath = Paths.get(line.substring(ENTRY_START_PREFIX.length()).trim()); + if (!currentPath.isAbsolute()) { + currentPath = rootBaseDir.resolve(currentPath); + } + if (!included.contains(currentPath)) { + return; + } + skipCount = 3; + return; + } + + if (!included.contains(currentPath)) { + return; + } + + if (skipCount > 0) { + skipCount--; + return; + } + + if (line.startsWith("@@ ")) { + Matcher matcher = START_LINE_IN_TARGET.matcher(line); + if (!matcher.find()) { + throw new IllegalStateException("Invalid block header: " + line); + } + lineNumInTarget = Integer.parseInt(matcher.group(1)); + return; + } + + parseContent(line); + } + + private void parseContent(String line) { + char firstChar = line.charAt(0); + if (firstChar == ' ') { + lineNumInTarget++; + } else if (firstChar == '+') { + changedLines + .computeIfAbsent(currentPath, path -> new HashSet<>()) + .add(lineNumInTarget); + lineNumInTarget++; + } + } + + Map> changedLines() { + return changedLines; + } + } +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/FindFork.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/FindFork.java new file mode 100644 index 00000000000..534dd361c8e --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/FindFork.java @@ -0,0 +1,118 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Optional; +import javax.annotation.CheckForNull; +import org.sonar.api.scanner.ScannerSide; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.tmatesoft.svn.core.ISVNLogEntryHandler; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNLogEntry; +import org.tmatesoft.svn.core.SVNLogEntryPath; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNRevision; +import org.tmatesoft.svn.core.wc.SVNStatus; + +import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager; + +@ScannerSide +public class FindFork { + private static final Logger LOG = Loggers.get(FindFork.class); + + private final SvnConfiguration configuration; + + public FindFork(SvnConfiguration configuration) { + this.configuration = configuration; + } + + @CheckForNull + public Instant findDate(Path location, String referenceBranch) throws SVNException { + ForkPoint forkPoint = find(location, referenceBranch); + if (forkPoint != null) { + return forkPoint.date(); + } + return null; + } + + @CheckForNull + public ForkPoint find(Path location, String referenceBranch) throws SVNException { + SVNClientManager clientManager = newSvnClientManager(configuration); + SVNRevision revision = getSvnRevision(location, clientManager); + LOG.debug("latest revision is " + revision); + String svnRefBranch = "/" + referenceBranch; + + SVNLogEntryHolder handler = new SVNLogEntryHolder(); + SVNRevision endRevision = SVNRevision.create(1); + SVNRevision startRevision = SVNRevision.create(revision.getNumber()); + + do { + clientManager.getLogClient().doLog(new File[] {location.toFile()}, startRevision, endRevision, true, true, -1, handler); + SVNLogEntry lastEntry = handler.getLastEntry(); + Optional copyFromReference = lastEntry.getChangedPaths().values().stream() + .filter(e -> e.getCopyPath() != null && e.getCopyPath().equals(svnRefBranch)) + .findFirst(); + + if (copyFromReference.isPresent()) { + return new ForkPoint(String.valueOf(copyFromReference.get().getCopyRevision()), Instant.ofEpochMilli(lastEntry.getDate().getTime())); + } + + if (lastEntry.getChangedPaths().isEmpty()) { + // shouldn't happen since it should only stop in revisions with changed paths + return null; + } + + SVNLogEntryPath firstChangedPath = lastEntry.getChangedPaths().values().iterator().next(); + if (firstChangedPath.getCopyPath() == null) { + // we walked the history to the root, and the last commit found had no copy reference. Must be the trunk, there is no fork point + return null; + } + + // TODO Looks like a revision can have multiple changed paths. Should we iterate through all of them? + startRevision = SVNRevision.create(firstChangedPath.getCopyRevision()); + } while (true); + + } + + private static SVNRevision getSvnRevision(Path location, SVNClientManager clientManager) throws SVNException { + SVNStatus svnStatus = clientManager.getStatusClient().doStatus(location.toFile(), false); + return svnStatus.getRevision(); + } + + /** + * Handler keeping only the last entry, and count how many entries have been seen. + */ + private static class SVNLogEntryHolder implements ISVNLogEntryHandler { + SVNLogEntry value; + + public SVNLogEntry getLastEntry() { + return value; + } + + @Override + public void handleLogEntry(SVNLogEntry svnLogEntry) { + this.value = svnLogEntry; + } + } +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ForkPoint.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ForkPoint.java new file mode 100644 index 00000000000..8569ce4ac77 --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/ForkPoint.java @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.time.Instant; + +public class ForkPoint { + private String commit; + private Instant date; + + public ForkPoint(String commit, Instant date) { + this.commit = commit; + this.date = date; + } + + public String commit() { + return commit; + } + + public Instant date() { + return date; + } + + @Override + public String toString() { + return "ForkPoint{" + + "commit='" + commit + '\'' + + ", date=" + date + + '}'; + } +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnBlameCommand.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnBlameCommand.java new file mode 100644 index 00000000000..bb328f1c98f --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnBlameCommand.java @@ -0,0 +1,118 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.util.List; +import org.sonar.api.batch.fs.FileSystem; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.scm.BlameCommand; +import org.sonar.api.batch.scm.BlameLine; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.tmatesoft.svn.core.SVNErrorCode; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNDiffOptions; +import org.tmatesoft.svn.core.wc.SVNLogClient; +import org.tmatesoft.svn.core.wc.SVNRevision; +import org.tmatesoft.svn.core.wc.SVNStatus; +import org.tmatesoft.svn.core.wc.SVNStatusClient; +import org.tmatesoft.svn.core.wc.SVNStatusType; + +import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager; + +public class SvnBlameCommand extends BlameCommand { + + private static final Logger LOG = Loggers.get(SvnBlameCommand.class); + private final SvnConfiguration configuration; + + public SvnBlameCommand(SvnConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public void blame(final BlameInput input, final BlameOutput output) { + FileSystem fs = input.fileSystem(); + LOG.debug("Working directory: " + fs.baseDir().getAbsolutePath()); + SVNClientManager clientManager = null; + try { + clientManager = newSvnClientManager(configuration); + for (InputFile inputFile : input.filesToBlame()) { + blame(clientManager, inputFile, output); + } + } finally { + if (clientManager != null) { + try { + clientManager.dispose(); + } catch (Exception e) { + LOG.warn("Unable to dispose SVN ClientManager", e); + } + } + } + } + + private static void blame(SVNClientManager clientManager, InputFile inputFile, BlameOutput output) { + String filename = inputFile.relativePath(); + + LOG.debug("Process file {}", filename); + + AnnotationHandler handler = new AnnotationHandler(); + try { + if (!checkStatus(clientManager, inputFile)) { + return; + } + SVNLogClient logClient = clientManager.getLogClient(); + logClient.setDiffOptions(new SVNDiffOptions(true, true, true)); + logClient.doAnnotate(inputFile.file(), SVNRevision.UNDEFINED, SVNRevision.create(1), SVNRevision.BASE, true, true, handler, null); + } catch (SVNException e) { + throw new IllegalStateException("Error when executing blame for file " + filename, e); + } + + List lines = handler.getLines(); + if (lines.size() == inputFile.lines() - 1) { + // SONARPLUGINS-3097 SVN do not report blame on last empty line + lines.add(lines.get(lines.size() - 1)); + } + output.blameResult(inputFile, lines); + } + + private static boolean checkStatus(SVNClientManager clientManager, InputFile inputFile) throws SVNException { + SVNStatusClient statusClient = clientManager.getStatusClient(); + try { + SVNStatus status = statusClient.doStatus(inputFile.file(), false); + if (status == null) { + LOG.debug("File {} returns no svn state. Skipping it.", inputFile); + return false; + } + if (status.getContentsStatus() != SVNStatusType.STATUS_NORMAL) { + LOG.debug("File {} is not versionned or contains local modifications. Skipping it.", inputFile); + return false; + } + } catch (SVNException e) { + if (SVNErrorCode.WC_PATH_NOT_FOUND.equals(e.getErrorMessage().getErrorCode()) + || SVNErrorCode.WC_NOT_WORKING_COPY.equals(e.getErrorMessage().getErrorCode())) { + LOG.debug("File {} is not versionned. Skipping it.", inputFile); + return false; + } + throw e; + } + return true; + } +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnConfiguration.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnConfiguration.java new file mode 100644 index 00000000000..83e0cc7641c --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnConfiguration.java @@ -0,0 +1,117 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import javax.annotation.CheckForNull; +import org.sonar.api.CoreProperties; +import org.sonar.api.PropertyType; +import org.sonar.api.config.Configuration; +import org.sonar.api.config.PropertyDefinition; +import org.sonar.api.resources.Qualifiers; +import org.sonar.api.scanner.ScannerSide; +import org.sonar.api.utils.MessageException; + +@ScannerSide +public class SvnConfiguration { + + private static final String CATEGORY_SVN = "SVN"; + public static final String USER_PROP_KEY = "sonar.svn.username"; + public static final String PRIVATE_KEY_PATH_PROP_KEY = "sonar.svn.privateKeyPath"; + public static final String PASSWORD_PROP_KEY = "sonar.svn.password.secured"; + public static final String PASSPHRASE_PROP_KEY = "sonar.svn.passphrase.secured"; + private final Configuration config; + + public SvnConfiguration(Configuration config) { + this.config = config; + } + + public static List getProperties() { + return Arrays.asList( + PropertyDefinition.builder(USER_PROP_KEY) + .name("Username") + .description("Username to be used for SVN server or SVN+SSH authentication") + .type(PropertyType.STRING) + .onQualifiers(Qualifiers.PROJECT) + .category(CoreProperties.CATEGORY_SCM) + .subCategory(CATEGORY_SVN) + .index(0) + .build(), + PropertyDefinition.builder(PASSWORD_PROP_KEY) + .name("Password") + .description("Password to be used for SVN server or SVN+SSH authentication") + .type(PropertyType.PASSWORD) + .onQualifiers(Qualifiers.PROJECT) + .category(CoreProperties.CATEGORY_SCM) + .subCategory(CATEGORY_SVN) + .index(1) + .build(), + PropertyDefinition.builder(PRIVATE_KEY_PATH_PROP_KEY) + .name("Path to private key file") + .description("Can be used instead of password for SVN+SSH authentication") + .type(PropertyType.STRING) + .onQualifiers(Qualifiers.PROJECT) + .category(CoreProperties.CATEGORY_SCM) + .subCategory(CATEGORY_SVN) + .index(2) + .build(), + PropertyDefinition.builder(PASSPHRASE_PROP_KEY) + .name("Passphrase") + .description("Optional passphrase of your private key file") + .type(PropertyType.PASSWORD) + .onQualifiers(Qualifiers.PROJECT) + .category(CoreProperties.CATEGORY_SCM) + .subCategory(CATEGORY_SVN) + .index(3) + .build()); + } + + @CheckForNull + public String username() { + return config.get(USER_PROP_KEY).orElse(null); + } + + @CheckForNull + public String password() { + return config.get(PASSWORD_PROP_KEY).orElse(null); + } + + @CheckForNull + public File privateKey() { + Optional privateKeyOpt = config.get(PRIVATE_KEY_PATH_PROP_KEY); + if (privateKeyOpt.isPresent()) { + File privateKeyFile = new File(privateKeyOpt.get()); + if (!privateKeyFile.exists() || !privateKeyFile.isFile() || !privateKeyFile.canRead()) { + throw MessageException.of("Unable to read private key from '" + privateKeyFile + "'"); + } + return privateKeyFile; + } + return null; + } + + @CheckForNull + public String passPhrase() { + return config.get(PASSPHRASE_PROP_KEY).orElse(null); + } + +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmProvider.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmProvider.java new file mode 100644 index 00000000000..1e44f89c63f --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmProvider.java @@ -0,0 +1,215 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import javax.annotation.CheckForNull; +import org.sonar.api.batch.scm.BlameCommand; +import org.sonar.api.batch.scm.ScmProvider; +import org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; +import org.tmatesoft.svn.core.SVNDepth; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNLogEntryPath; +import org.tmatesoft.svn.core.SVNNodeKind; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNDiffClient; +import org.tmatesoft.svn.core.wc.SVNInfo; +import org.tmatesoft.svn.core.wc.SVNLogClient; +import org.tmatesoft.svn.core.wc.SVNRevision; +import org.tmatesoft.svn.core.wc.SVNWCClient; + +import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager; + +public class SvnScmProvider extends ScmProvider { + + private static final Logger LOG = Loggers.get(SvnScmProvider.class); + + private final SvnConfiguration configuration; + private final SvnBlameCommand blameCommand; + private final FindFork findFork; + + public SvnScmProvider(SvnConfiguration configuration, SvnBlameCommand blameCommand, FindFork findFork) { + this.configuration = configuration; + this.blameCommand = blameCommand; + this.findFork = findFork; + } + + @Override + public String key() { + return "svn"; + } + + @Override + public boolean supports(File baseDir) { + File folder = baseDir; + while (folder != null) { + if (new File(folder, ".svn").exists()) { + return true; + } + folder = folder.getParentFile(); + } + return false; + } + + @Override + public BlameCommand blameCommand() { + return blameCommand; + } + + @CheckForNull + @Override + public Set branchChangedFiles(String targetBranchName, Path rootBaseDir) { + SVNClientManager clientManager = null; + try { + clientManager = newSvnClientManager(configuration); + return computeChangedPaths(rootBaseDir, clientManager); + } catch (SVNException e) { + LOG.warn(e.getMessage()); + } finally { + if (clientManager != null) { + try { + clientManager.dispose(); + } catch (Exception e) { + LOG.warn("Unable to dispose SVN ClientManager", e); + } + } + } + + return null; + } + + static Set computeChangedPaths(Path projectBasedir, SVNClientManager clientManager) throws SVNException { + SVNWCClient wcClient = clientManager.getWCClient(); + SVNInfo svnInfo = wcClient.doInfo(projectBasedir.toFile(), null); + + // SVN path of the repo root, for example: /C:/Users/JANOSG~1/AppData/Local/Temp/x/y + Path svnRootPath = toPath(svnInfo.getRepositoryRootURL()); + + // the svn root path may be "" for urls like http://svnserver/ + // -> set it to "/" to avoid crashing when using Path.relativize later + if (svnRootPath.equals(Paths.get(""))) { + svnRootPath = Paths.get("/"); + } + + // SVN path of projectBasedir, for example: /C:/Users/JANOSG~1/AppData/Local/Temp/x/y/branches/b1 + Path svnProjectPath = toPath(svnInfo.getURL()); + // path of projectBasedir, as "absolute path within the SVN repo", for example: /branches/b1 + Path inRepoProjectPath = Paths.get("/").resolve(svnRootPath.relativize(svnProjectPath)); + + // We inspect "svn log" from latest revision until copy-point. + // The same path may appear in multiple commits, the ordering of changes and removals is important. + Set paths = new HashSet<>(); + Set removed = new HashSet<>(); + + SVNLogClient svnLogClient = clientManager.getLogClient(); + svnLogClient.doLog(new File[] {projectBasedir.toFile()}, null, null, null, true, true, 0, svnLogEntry -> + svnLogEntry.getChangedPaths().values().forEach(entry -> { + if (entry.getKind().equals(SVNNodeKind.FILE)) { + Path path = projectBasedir.resolve(inRepoProjectPath.relativize(Paths.get(entry.getPath()))); + if (isModified(entry)) { + // Skip if the path is removed in a more recent commit + if (!removed.contains(path)) { + paths.add(path); + } + } else if (entry.getType() == SVNLogEntryPath.TYPE_DELETED) { + removed.add(path); + } + } + })); + return paths; + } + + private static Path toPath(SVNURL svnUrl) { + if ("file".equals(svnUrl.getProtocol())) { + try { + return Paths.get(new URL("file", svnUrl.getHost(), svnUrl.getPath()).toURI()); + } catch (URISyntaxException | MalformedURLException e) { + throw new IllegalStateException(e); + } + } + return Paths.get(svnUrl.getURIEncodedPath()); + } + + private static boolean isModified(SVNLogEntryPath entry) { + return entry.getType() == SVNLogEntryPath.TYPE_ADDED + || entry.getType() == SVNLogEntryPath.TYPE_MODIFIED; + } + + @CheckForNull + @Override + public Map> branchChangedLines(String targetBranchName, Path rootBaseDir, Set changedFiles) { + SVNClientManager clientManager = null; + try { + clientManager = newSvnClientManager(configuration); + + // find reference revision number: the copy point + SVNLogClient svnLogClient = clientManager.getLogClient(); + long[] revisionCounter = {0}; + svnLogClient.doLog(new File[] {rootBaseDir.toFile()}, null, null, null, true, true, 0, + svnLogEntry -> revisionCounter[0] = svnLogEntry.getRevision()); + + long startRev = revisionCounter[0]; + + SVNDiffClient svnDiffClient = clientManager.getDiffClient(); + File path = rootBaseDir.toFile(); + ChangedLinesComputer computer = newChangedLinesComputer(rootBaseDir, changedFiles); + svnDiffClient.doDiff(path, SVNRevision.create(startRev), path, SVNRevision.WORKING, SVNDepth.INFINITY, false, computer.receiver(), null); + return computer.changedLines(); + } catch (Exception e) { + LOG.warn("Failed to get changed lines from Subversion", e); + } finally { + if (clientManager != null) { + try { + clientManager.dispose(); + } catch (Exception e) { + LOG.warn("Unable to dispose SVN ClientManager", e); + } + } + } + + return null; + } + + @CheckForNull + @Override + public Instant forkDate(String referenceBranch, Path rootBaseDir) { + try { + return findFork.findDate(rootBaseDir, referenceBranch); + } catch (SVNException e) { + LOG.warn("Unable to find fork date with '" + referenceBranch + "'", e); + return null; + } + } + + ChangedLinesComputer newChangedLinesComputer(Path rootBaseDir, Set changedFiles) { + return new ChangedLinesComputer(rootBaseDir, changedFiles); + } +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmSupport.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmSupport.java new file mode 100644 index 00000000000..03182cde230 --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/SvnScmSupport.java @@ -0,0 +1,62 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.util.Arrays; +import java.util.List; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; +import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager; +import org.tmatesoft.svn.core.wc.ISVNOptions; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNWCUtil; + +public class SvnScmSupport { + private SvnScmSupport() { + // static only + } + + static SVNClientManager newSvnClientManager(SvnConfiguration configuration) { + ISVNOptions options = SVNWCUtil.createDefaultOptions(true); + final char[] passwordValue = getCharsOrNull(configuration.password()); + final char[] passPhraseValue = getCharsOrNull(configuration.passPhrase()); + ISVNAuthenticationManager authManager = SVNWCUtil.createDefaultAuthenticationManager( + null, + configuration.username(), + passwordValue, + configuration.privateKey(), + passPhraseValue, + false); + return SVNClientManager.newInstance(options, authManager); + } + + @CheckForNull + private static char[] getCharsOrNull(@Nullable String s) { + return s != null ? s.toCharArray() : null; + } + + public static List getObjects() { + return Arrays.asList(SvnScmProvider.class, + SvnBlameCommand.class, + SvnConfiguration.class, + FindFork.class, + SvnConfiguration.getProperties()); + } +} diff --git a/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/package-info.java b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/package-info.java new file mode 100644 index 00000000000..3af724b1ea4 --- /dev/null +++ b/sonar-scanner-engine/src/main/java/org/sonar/scm/svn/package-info.java @@ -0,0 +1,24 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +@ParametersAreNonnullByDefault +package org.sonar.scm.svn; + +import javax.annotation.ParametersAreNonnullByDefault; + diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java index 803b02088be..10c33f9af38 100644 --- a/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/git/GitScmSupportTest.java @@ -27,7 +27,7 @@ public class GitScmSupportTest { @Test public void getClasses() { - assertThat(GitScmSupport.getClasses()).hasSize(3); + assertThat(GitScmSupport.getObjects()).hasSize(3); } } diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/ChangedLinesComputerTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/ChangedLinesComputerTest.java new file mode 100644 index 00000000000..bef5eb6c57c --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/ChangedLinesComputerTest.java @@ -0,0 +1,195 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.junit.Test; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; + +public class ChangedLinesComputerTest { + + private final Path rootBaseDir = Paths.get("/foo"); + private final ChangedLinesComputer underTest = new ChangedLinesComputer(rootBaseDir, new HashSet<>(Arrays.asList( + rootBaseDir.resolve("sample1"), + rootBaseDir.resolve("sample2"), + rootBaseDir.resolve("sample3"), + rootBaseDir.resolve("sample4")))); + + @Test + public void do_not_count_deleted_line() throws IOException { + String example = "Index: sample1\n" + + "===================================================================\n" + + "--- a/sample1\n" + + "+++ b/sample1\n" + + "@@ -1 +0,0 @@\n" + + "-deleted line\n"; + + printDiff(example); + assertThat(underTest.changedLines()).isEmpty(); + } + + @Test + public void count_single_added_line() throws IOException { + String example = "Index: sample1\n" + + "===================================================================\n" + + "--- a/sample1\n" + + "+++ b/sample1\n" + + "@@ -0,0 +1 @@\n" + + "+added line\n"; + + printDiff(example); + assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), singleton(1))); + } + + @Test + public void count_multiple_added_lines() throws IOException { + String example = "Index: sample1\n" + + "===================================================================\n" + + "--- a/sample1\n" + + "+++ b/sample1\n" + + "@@ -1 +1,3 @@\n" + + " same line\n" + + "+added line 1\n" + + "+added line 2\n"; + + printDiff(example); + assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), new HashSet<>(Arrays.asList(2, 3)))); + } + + @Test + public void handle_index_using_absolute_paths() throws IOException { + String example = "Index: /foo/sample1\n" + + "===================================================================\n" + + "--- a/sample1\n" + + "+++ b/sample1\n" + + "@@ -1 +1,3 @@\n" + + " same line\n" + + "+added line 1\n" + + "+added line 2\n"; + + printDiff(example); + assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), new HashSet<>(Arrays.asList(2, 3)))); + } + + @Test + public void compute_from_multiple_hunks() throws IOException { + String example = "Index: sample1\n" + + "===================================================================\n" + + "--- lao\t2002-02-21 23:30:39.942229878 -0800\n" + + "+++ tzu\t2002-02-21 23:30:50.442260588 -0800\n" + + "@@ -1,7 +1,6 @@\n" + + "-The Way that can be told of is not the eternal Way;\n" + + "-The name that can be named is not the eternal name.\n" + + " The Nameless is the origin of Heaven and Earth;\n" + + "-The Named is the mother of all things.\n" + + "+The named is the mother of all things.\n" + + "+\n" + + " Therefore let there always be non-being,\n" + + " so we may see their subtlety,\n" + + " And let there always be being,\n" + + "@@ -9,3 +8,6 @@\n" + + " The two are the same,\n" + + " But after they are produced,\n" + + " they have different names.\n" + + "+They both may be called deep and profound.\n" + + "+Deeper and more profound,\n" + + "+The door of all subtleties!\n"; + printDiff(example); + assertThat(underTest.changedLines()).isEqualTo(Collections.singletonMap(rootBaseDir.resolve("sample1"), new HashSet<>(Arrays.asList(2, 3, 11, 12, 13)))); + } + + @Test(expected = IllegalStateException.class) + public void crash_on_invalid_start_line_format() throws IOException { + String example = "Index: sample1\n" + + "===================================================================\n" + + "--- a/sample1\n" + + "+++ b/sample1\n" + + "@@ -1 +x1,3 @@\n" + + " same line\n" + + "+added line 1\n" + + "+added line 2\n"; + + printDiff(example); + underTest.changedLines(); + } + + @Test + public void parse_diff_with_multiple_files() throws IOException { + String example = "Index: sample1\n" + + "===================================================================\n" + + "--- a/sample1\n" + + "+++ b/sample1\n" + + "@@ -1 +0,0 @@\n" + + "-deleted line\n" + + "Index: sample2\n" + + "===================================================================\n" + + "--- a/sample2\n" + + "+++ b/sample2\n" + + "@@ -0,0 +1 @@\n" + + "+added line\n" + + "Index: sample3\n" + + "===================================================================\n" + + "--- a/sample3\n" + + "+++ b/sample3\n" + + "@@ -0,0 +1,2 @@\n" + + "+added line 1\n" + + "+added line 2\n" + + "Index: sample3-not-included\n" + + "===================================================================\n" + + "--- a/sample3-not-included\n" + + "+++ b/sample3-not-included\n" + + "@@ -0,0 +1,2 @@\n" + + "+added line 1\n" + + "+added line 2\n" + + "Index: sample4\n" + + "===================================================================\n" + + "--- a/sample4\n" + + "+++ b/sample4\n" + + "@@ -1 +1,3 @@\n" + + " same line\n" + + "+added line 1\n" + + "+added line 2\n"; + + printDiff(example); + Map> expected = new HashMap<>(); + expected.put(rootBaseDir.resolve("sample2"), Collections.singleton(1)); + expected.put(rootBaseDir.resolve("sample3"), new HashSet<>(Arrays.asList(1, 2))); + expected.put(rootBaseDir.resolve("sample4"), new HashSet<>(Arrays.asList(2, 3))); + + assertThat(underTest.changedLines()).isEqualTo(expected); + } + + private void printDiff(String unifiedDiff) throws IOException { + try (OutputStreamWriter writer = new OutputStreamWriter(underTest.receiver())) { + writer.write(unifiedDiff); + } + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/FindForkTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/FindForkTest.java new file mode 100644 index 00000000000..5bbc7c4d109 --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/FindForkTest.java @@ -0,0 +1,143 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tmatesoft.svn.core.SVNException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +public class FindForkTest { + + @ClassRule + public static TemporaryFolder temp = new TemporaryFolder(); + + private static SvnTester svnTester; + + private static Path trunk; + private static Path b1; + private static Path b2; + + private FindFork findFork; + + @BeforeClass + public static void before() throws IOException, SVNException { + svnTester = new SvnTester(temp.newFolder().toPath()); + + trunk = temp.newFolder("trunk").toPath(); + svnTester.checkout(trunk, "trunk"); + createAndCommitFile(trunk, "file-1-commit-in-trunk.xoo"); + createAndCommitFile(trunk, "file-2-commit-in-trunk.xoo"); + createAndCommitFile(trunk, "file-3-commit-in-trunk.xoo"); + svnTester.checkout(trunk, "trunk"); + + svnTester.createBranch("b1"); + b1 = temp.newFolder("branches", "b1").toPath(); + svnTester.checkout(b1, "branches/b1"); + createAndCommitFile(b1, "file-1-commit-in-b1.xoo"); + createAndCommitFile(b1, "file-2-commit-in-b1.xoo"); + createAndCommitFile(b1, "file-3-commit-in-b1.xoo"); + svnTester.checkout(b1, "branches/b1"); + + svnTester.createBranch("branches/b1", "b2"); + b2 = temp.newFolder("branches", "b2").toPath(); + svnTester.checkout(b2, "branches/b2"); + + createAndCommitFile(b2, "file-1-commit-in-b2.xoo"); + createAndCommitFile(b2, "file-2-commit-in-b2.xoo"); + createAndCommitFile(b2, "file-3-commit-in-b2.xoo"); + svnTester.checkout(b2, "branches/b2"); + } + + @Before + public void setUp() { + SvnConfiguration configurationMock = mock(SvnConfiguration.class); + findFork = new FindFork(configurationMock); + } + + @Test + public void testEmptyBranch() throws SVNException, IOException { + svnTester.createBranch("empty"); + Path empty = temp.newFolder("branches", "empty").toPath(); + + svnTester.checkout(empty, "branches/empty"); + ForkPoint forkPoint = findFork.find(empty, "unknown"); + assertThat(forkPoint).isNull(); + } + + @Test + public void returnNoDate() throws SVNException { + FindFork findFork = new FindFork(mock(SvnConfiguration.class)) { + @Override + public ForkPoint find(Path location, String referenceBranch) { + return null; + } + }; + + assertThat(findFork.findDate(Paths.get(""), "branch")).isNull(); + } + + @Test + public void testTrunk() throws SVNException { + ForkPoint forkPoint = findFork.find(trunk, "unknown"); + assertThat(forkPoint).isNull(); + } + + @Test + public void testB1() throws SVNException { + ForkPoint forkPoint = findFork.find(b1, "trunk"); + assertThat(forkPoint.commit()).isEqualTo("5"); + } + + @Test + public void testB2() throws SVNException { + ForkPoint forkPoint = findFork.find(b2, "branches/b1"); + assertThat(forkPoint.commit()).isEqualTo("9"); + } + + @Test + public void testB2Date() throws SVNException { + assertThat(findFork.findDate(b2, "branches/b1")).isNotNull(); + } + + @Test + public void testB2FromTrunk() throws SVNException { + ForkPoint forkPoint = findFork.find(b2, "trunk"); + assertThat(forkPoint.commit()).isEqualTo("5"); + } + + private static void createAndCommitFile(Path worktree, String filename, String content) throws IOException, SVNException { + svnTester.createFile(worktree, filename, content); + svnTester.add(worktree, filename); + svnTester.commit(worktree); + } + + private static void createAndCommitFile(Path worktree, String filename) throws IOException, SVNException { + createAndCommitFile(worktree, filename, filename + "\n"); + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnBlameCommandTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnBlameCommandTest.java new file mode 100644 index 00000000000..930853b6a18 --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnBlameCommandTest.java @@ -0,0 +1,321 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Date; +import java.util.Enumeration; +import java.util.List; +import java.util.stream.IntStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.mockito.ArgumentCaptor; +import org.sonar.api.batch.fs.FileSystem; +import org.sonar.api.batch.fs.internal.DefaultInputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; +import org.sonar.api.batch.scm.BlameCommand.BlameInput; +import org.sonar.api.batch.scm.BlameCommand.BlameOutput; +import org.sonar.api.batch.scm.BlameLine; +import org.tmatesoft.svn.core.SVNDepth; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager; +import org.tmatesoft.svn.core.internal.wc2.compat.SvnCodec; +import org.tmatesoft.svn.core.wc.ISVNOptions; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNRevision; +import org.tmatesoft.svn.core.wc.SVNUpdateClient; +import org.tmatesoft.svn.core.wc.SVNWCUtil; +import org.tmatesoft.svn.core.wc2.SvnCheckout; +import org.tmatesoft.svn.core.wc2.SvnTarget; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@RunWith(Parameterized.class) +public class SvnBlameCommandTest { + + /* + * Note about SONARSCSVN-11: The case of a project baseDir is in a subFolder of working copy is part of method tests by default + */ + + private static final String DUMMY_JAVA = "src/main/java/org/dummy/Dummy.java"; + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private FileSystem fs; + private BlameInput input; + private String serverVersion; + private int wcVersion; + + @Parameters(name = "SVN server version {0}, WC version {1}") + public static Iterable data() { + return Arrays.asList(new Object[][] {{"1.6", 10}, {"1.7", 29}, {"1.8", 31}, {"1.9", 31}}); + } + + public SvnBlameCommandTest(String serverVersion, int wcVersion) { + this.serverVersion = serverVersion; + this.wcVersion = wcVersion; + } + + @Before + public void prepare() { + fs = mock(FileSystem.class); + input = mock(BlameInput.class); + when(input.fileSystem()).thenReturn(fs); + } + + @Test + public void testParsingOfOutput() throws Exception { + File repoDir = unzip("repo-svn.zip"); + + String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn")); + File baseDir = new File(checkout(scmUrl), "dummy-svn"); + + when(fs.baseDir()).thenReturn(baseDir); + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA) + .setLines(27) + .setModuleBaseDir(baseDir.toPath()) + .build(); + + BlameOutput blameResult = mock(BlameOutput.class); + when(input.filesToBlame()).thenReturn(singletonList(inputFile)); + + newSvnBlameCommand().blame(input, blameResult); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + verify(blameResult).blameResult(eq(inputFile), captor.capture()); + List result = captor.getValue(); + assertThat(result).hasSize(27); + Date commitDate = new Date(1342691097393L); + BlameLine[] expected = IntStream.rangeClosed(1, 27).mapToObj(i -> new BlameLine().date(commitDate).revision("2").author("dgageot")).toArray(BlameLine[]::new); + assertThat(result).containsExactly(expected); + } + + private File unzip(String repoName) throws IOException { + File repoDir = temp.newFolder(); + try { + javaUnzip(Paths.get(this.getClass().getResource("test-repos").toURI()).resolve(serverVersion).resolve(repoName).toFile(), repoDir); + return repoDir; + } catch (URISyntaxException e) { + throw new IOException(e); + } + } + + private File checkout(String scmUrl) throws Exception { + ISVNOptions options = SVNWCUtil.createDefaultOptions(true); + ISVNAuthenticationManager isvnAuthenticationManager = SVNWCUtil.createDefaultAuthenticationManager(null, null, (char[]) null, false); + SVNClientManager svnClientManager = SVNClientManager.newInstance(options, isvnAuthenticationManager); + File out = temp.newFolder(); + SVNUpdateClient updateClient = svnClientManager.getUpdateClient(); + SvnCheckout co = updateClient.getOperationsFactory().createCheckout(); + co.setUpdateLocksOnDemand(updateClient.isUpdateLocksOnDemand()); + co.setSource(SvnTarget.fromURL(SVNURL.parseURIEncoded(scmUrl), SVNRevision.HEAD)); + co.setSingleTarget(SvnTarget.fromFile(out)); + co.setRevision(SVNRevision.HEAD); + co.setDepth(SVNDepth.INFINITY); + co.setAllowUnversionedObstructions(false); + co.setIgnoreExternals(updateClient.isIgnoreExternals()); + co.setExternalsHandler(SvnCodec.externalsHandler(updateClient.getExternalsHandler())); + co.setTargetWorkingCopyFormat(wcVersion); + co.run(); + return out; + } + + @Test + public void testParsingOfOutputWithMergeHistory() throws Exception { + File repoDir = unzip("repo-svn-with-merge.zip"); + + String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn")); + File baseDir = new File(checkout(scmUrl), "dummy-svn/trunk"); + + when(fs.baseDir()).thenReturn(baseDir); + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA) + .setLines(27) + .setModuleBaseDir(baseDir.toPath()) + .build(); + + BlameOutput blameResult = mock(BlameOutput.class); + when(input.filesToBlame()).thenReturn(singletonList(inputFile)); + + newSvnBlameCommand().blame(input, blameResult); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + verify(blameResult).blameResult(eq(inputFile), captor.capture()); + List result = captor.getValue(); + assertThat(result).hasSize(27); + Date commitDate = new Date(1342691097393L); + Date revision6Date = new Date(1415262184300L); + + BlameLine[] expected = IntStream.rangeClosed(1, 27).mapToObj(i -> { + if (i == 2 || i == 24) { + return new BlameLine().date(revision6Date).revision("6").author("henryju"); + } else { + return new BlameLine().date(commitDate).revision("2").author("dgageot"); + } + }).toArray(BlameLine[]::new); + + assertThat(result).containsExactly(expected); + } + + @Test + public void shouldNotFailIfFileContainsLocalModification() throws Exception { + File repoDir = unzip("repo-svn.zip"); + + String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn")); + File baseDir = new File(checkout(scmUrl), "dummy-svn"); + + when(fs.baseDir()).thenReturn(baseDir); + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA) + .setLines(28) + .setModuleBaseDir(baseDir.toPath()) + .build(); + + Files.write(baseDir.toPath().resolve(DUMMY_JAVA), "\n//foo".getBytes(), StandardOpenOption.APPEND); + + BlameOutput blameResult = mock(BlameOutput.class); + when(input.filesToBlame()).thenReturn(singletonList(inputFile)); + + newSvnBlameCommand().blame(input, blameResult); + verifyNoInteractions(blameResult); + } + + // SONARSCSVN-7 + @Test + public void shouldNotFailOnWrongFilename() throws Exception { + File repoDir = unzip("repo-svn.zip"); + + String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn")); + File baseDir = new File(checkout(scmUrl), "dummy-svn"); + + when(fs.baseDir()).thenReturn(baseDir); + DefaultInputFile inputFile = new TestInputFileBuilder("foo", DUMMY_JAVA.toLowerCase()) + .setLines(27) + .setModuleBaseDir(baseDir.toPath()) + .build(); + + BlameOutput blameResult = mock(BlameOutput.class); + when(input.filesToBlame()).thenReturn(singletonList(inputFile)); + + newSvnBlameCommand().blame(input, blameResult); + verifyNoInteractions(blameResult); + } + + @Test + public void shouldNotFailOnUncommitedFile() throws Exception { + File repoDir = unzip("repo-svn.zip"); + + String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn")); + File baseDir = new File(checkout(scmUrl), "dummy-svn"); + + when(fs.baseDir()).thenReturn(baseDir); + String relativePath = "src/main/java/org/dummy/Dummy2.java"; + DefaultInputFile inputFile = new TestInputFileBuilder("foo", relativePath) + .setLines(28) + .setModuleBaseDir(baseDir.toPath()) + .build(); + + Files.write(baseDir.toPath().resolve(relativePath), "package org.dummy;\npublic class Dummy2 {}".getBytes()); + + BlameOutput blameResult = mock(BlameOutput.class); + when(input.filesToBlame()).thenReturn(singletonList(inputFile)); + + newSvnBlameCommand().blame(input, blameResult); + verifyNoInteractions(blameResult); + } + + @Test + public void shouldNotFailOnUncommitedDir() throws Exception { + File repoDir = unzip("repo-svn.zip"); + + String scmUrl = "file:///" + unixPath(new File(repoDir, "repo-svn")); + File baseDir = new File(checkout(scmUrl), "dummy-svn"); + + when(fs.baseDir()).thenReturn(baseDir); + String relativePath = "src/main/java/org/dummy2/dummy/Dummy.java"; + DefaultInputFile inputFile = new TestInputFileBuilder("foo", relativePath) + .setLines(28) + .setModuleBaseDir(baseDir.toPath()) + .build(); + + Path filepath = new File(baseDir, relativePath).toPath(); + Files.createDirectories(filepath.getParent()); + Files.write(filepath, "package org.dummy;\npublic class Dummy {}".getBytes()); + + BlameOutput blameResult = mock(BlameOutput.class); + when(input.filesToBlame()).thenReturn(singletonList(inputFile)); + + newSvnBlameCommand().blame(input, blameResult); + verifyNoInteractions(blameResult); + } + + private static void javaUnzip(File zip, File toDir) { + try { + try (ZipFile zipFile = new ZipFile(zip)) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + File to = new File(toDir, entry.getName()); + if (entry.isDirectory()) { + Files.createDirectories(to.toPath()); + } else { + File parent = to.getParentFile(); + if (parent != null) { + Files.createDirectories(parent.toPath()); + } + + Files.copy(zipFile.getInputStream(entry), to.toPath()); + } + } + } + } catch (Exception e) { + throw new IllegalStateException("Fail to unzip " + zip + " to " + toDir, e); + } + } + + private static String unixPath(File file) { + return file.getAbsolutePath().replace('\\', '/'); + } + + private SvnBlameCommand newSvnBlameCommand() { + return new SvnBlameCommand(mock(SvnConfiguration.class)); + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnConfigurationTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnConfigurationTest.java new file mode 100644 index 00000000000..7a765b28160 --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnConfigurationTest.java @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.sonar.api.config.PropertyDefinitions; +import org.sonar.api.config.internal.MapSettings; +import org.sonar.api.utils.System2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +public class SvnConfigurationTest { + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void sanityCheck() throws Exception { + MapSettings settings = new MapSettings(new PropertyDefinitions(System2.INSTANCE, SvnConfiguration.getProperties())); + SvnConfiguration config = new SvnConfiguration(settings.asConfig()); + + assertThat(config.username()).isNull(); + assertThat(config.password()).isNull(); + + settings.setProperty(SvnConfiguration.USER_PROP_KEY, "foo"); + assertThat(config.username()).isEqualTo("foo"); + + settings.setProperty(SvnConfiguration.PASSWORD_PROP_KEY, "pwd"); + assertThat(config.password()).isEqualTo("pwd"); + + settings.setProperty(SvnConfiguration.PASSPHRASE_PROP_KEY, "pass"); + assertThat(config.passPhrase()).isEqualTo("pass"); + + assertThat(config.privateKey()).isNull(); + File fakeKey = temp.newFile(); + settings.setProperty(SvnConfiguration.PRIVATE_KEY_PATH_PROP_KEY, fakeKey.getAbsolutePath()); + assertThat(config.privateKey()).isEqualTo(fakeKey); + + settings.setProperty(SvnConfiguration.PRIVATE_KEY_PATH_PROP_KEY, "/not/exists"); + try { + config.privateKey(); + fail("Expected exception"); + } catch (Exception e) { + assertThat(e).hasMessageContaining("Unable to read private key from "); + } + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmProviderTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmProviderTest.java new file mode 100644 index 00000000000..c815fdda0bf --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmProviderTest.java @@ -0,0 +1,325 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.sonar.api.batch.scm.ScmProvider; +import org.tmatesoft.svn.core.SVNCancelException; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNInfo; +import org.tmatesoft.svn.core.wc.SVNLogClient; +import org.tmatesoft.svn.core.wc.SVNWCClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SvnScmProviderTest { + + // Sample content for unified diffs + // http://www.gnu.org/software/diffutils/manual/html_node/Example-Unified.html#Example-Unified + private static final String CONTENT_LAO = "The Way that can be told of is not the eternal Way;\n" + + "The name that can be named is not the eternal name.\n" + + "The Nameless is the origin of Heaven and Earth;\n" + + "The Named is the mother of all things.\n" + + "Therefore let there always be non-being,\n" + + " so we may see their subtlety,\n" + + "And let there always be being,\n" + + " so we may see their outcome.\n" + + "The two are the same,\n" + + "But after they are produced,\n" + + " they have different names.\n"; + + private static final String CONTENT_TZU = "The Nameless is the origin of Heaven and Earth;\n" + + "The named is the mother of all things.\n" + + "\n" + + "Therefore let there always be non-being,\n" + + " so we may see their subtlety,\n" + + "And let there always be being,\n" + + " so we may see their outcome.\n" + + "The two are the same,\n" + + "But after they are produced,\n" + + " they have different names.\n" + + "They both may be called deep and profound.\n" + + "Deeper and more profound,\n" + + "The door of all subtleties!"; + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private FindFork findFork = mock(FindFork.class); + private SvnConfiguration config = mock(SvnConfiguration.class); + private SvnTester svnTester; + + @Before + public void before() throws IOException, SVNException { + svnTester = new SvnTester(temp.newFolder().toPath()); + + Path worktree = temp.newFolder().toPath(); + svnTester.checkout(worktree, "trunk"); + createAndCommitFile(worktree, "file-in-first-commit.xoo"); + } + + @Test + public void sanityCheck() { + SvnBlameCommand blameCommand = new SvnBlameCommand(config); + SvnScmProvider svnScmProvider = new SvnScmProvider(config, blameCommand, findFork); + assertThat(svnScmProvider.key()).isEqualTo("svn"); + assertThat(svnScmProvider.blameCommand()).isEqualTo(blameCommand); + } + + @Test + public void testAutodetection() throws IOException { + ScmProvider scmBranchProvider = newScmProvider(); + + File baseDirEmpty = temp.newFolder(); + assertThat(scmBranchProvider.supports(baseDirEmpty)).isFalse(); + + File svnBaseDir = temp.newFolder(); + Files.createDirectory(svnBaseDir.toPath().resolve(".svn")); + assertThat(scmBranchProvider.supports(svnBaseDir)).isTrue(); + + File svnBaseDirSubFolder = temp.newFolder(); + Files.createDirectory(svnBaseDirSubFolder.toPath().resolve(".svn")); + File projectBaseDir = new File(svnBaseDirSubFolder, "folder"); + Files.createDirectory(projectBaseDir.toPath()); + assertThat(scmBranchProvider.supports(projectBaseDir)).isTrue(); + } + + @Test + public void branchChangedFiles_and_lines_from_diverged() throws IOException, SVNException { + Path trunk = temp.newFolder().toPath(); + svnTester.checkout(trunk, "trunk"); + createAndCommitFile(trunk, "file-m1.xoo"); + createAndCommitFile(trunk, "file-m2.xoo"); + createAndCommitFile(trunk, "file-m3.xoo"); + createAndCommitFile(trunk, "lao.txt", CONTENT_LAO); + + // create branch from trunk + svnTester.createBranch("b1"); + + // still on trunk + appendToAndCommitFile(trunk, "file-m3.xoo"); + createAndCommitFile(trunk, "file-m4.xoo"); + + Path b1 = temp.newFolder().toPath(); + svnTester.checkout(b1, "branches/b1"); + Files.createDirectories(b1.resolve("sub")); + createAndCommitFile(b1, "sub/file-b1.xoo"); + appendToAndCommitFile(b1, "file-m1.xoo"); + deleteAndCommitFile(b1, "file-m2.xoo"); + + createAndCommitFile(b1, "file-m5.xoo"); + deleteAndCommitFile(b1, "file-m5.xoo"); + + svnCopyAndCommitFile(b1, "file-m1.xoo", "file-m1-copy.xoo"); + appendToAndCommitFile(b1, "file-m1.xoo"); + + // modify file without committing it -> should not be included (think generated files) + svnTester.appendToFile(b1, "file-m3.xoo"); + + svnTester.update(b1); + + Set changedFiles = newScmProvider().branchChangedFiles("trunk", b1); + assertThat(changedFiles) + .containsExactlyInAnyOrder( + b1.resolve("sub/file-b1.xoo"), + b1.resolve("file-m1.xoo"), + b1.resolve("file-m1-copy.xoo")); + + // use a subset of changed files for .branchChangedLines to verify only requested files are returned + assertThat(changedFiles.remove(b1.resolve("sub/file-b1.xoo"))).isTrue(); + + // generate common sample diff + createAndCommitFile(b1, "lao.txt", CONTENT_TZU); + changedFiles.add(b1.resolve("lao.txt")); + + // a file that should not yield any results + changedFiles.add(b1.resolve("nonexistent")); + + // modify file without committing to it + svnTester.appendToFile(b1, "file-m1.xoo"); + + Map> expected = new HashMap<>(); + expected.put(b1.resolve("lao.txt"), new HashSet<>(Arrays.asList(2, 3, 11, 12, 13))); + expected.put(b1.resolve("file-m1.xoo"), new HashSet<>(Arrays.asList(2, 3, 4))); + expected.put(b1.resolve("file-m1-copy.xoo"), new HashSet<>(Arrays.asList(1, 2))); + + assertThat(newScmProvider().branchChangedLines("trunk", b1, changedFiles)) + .isEqualTo(expected); + + assertThat(newScmProvider().branchChangedLines("trunk", b1, Collections.singleton(b1.resolve("nonexistent")))) + .isEmpty(); + } + + @Test + public void branchChangedFiles_should_return_empty_when_no_local_changes() throws IOException, SVNException { + Path b1 = temp.newFolder().toPath(); + svnTester.createBranch("b1"); + svnTester.checkout(b1, "branches/b1"); + + assertThat(newScmProvider().branchChangedFiles("b1", b1)).isEmpty(); + } + + @Test + public void branchChangedFiles_should_return_null_when_repo_nonexistent() throws IOException { + assertThat(newScmProvider().branchChangedFiles("trunk", temp.newFolder().toPath())).isNull(); + } + + @Test + public void branchChangedFiles_should_return_null_when_dir_nonexistent() { + assertThat(newScmProvider().branchChangedFiles("trunk", temp.getRoot().toPath().resolve("nonexistent"))).isNull(); + } + + @Test + public void branchChangedLines_should_return_null_when_repo_nonexistent() throws IOException { + assertThat(newScmProvider().branchChangedLines("trunk", temp.newFolder().toPath(), Collections.emptySet())).isNull(); + } + + @Test + public void branchChangedLines_should_return_null_when_dir_nonexistent() { + assertThat(newScmProvider().branchChangedLines("trunk", temp.getRoot().toPath().resolve("nonexistent"), Collections.emptySet())).isNull(); + } + + @Test + public void branchChangedLines_should_return_empty_when_no_local_changes() throws IOException, SVNException { + Path b1 = temp.newFolder().toPath(); + svnTester.createBranch("b1"); + svnTester.checkout(b1, "branches/b1"); + + assertThat(newScmProvider().branchChangedLines("b1", b1, Collections.emptySet())).isEmpty(); + } + + @Test + public void branchChangedLines_should_return_null_when_invalid_diff_format() throws IOException, SVNException { + Path b1 = temp.newFolder().toPath(); + svnTester.createBranch("b1"); + svnTester.checkout(b1, "branches/b1"); + + SvnScmProvider scmProvider = new SvnScmProvider(config, new SvnBlameCommand(config), findFork) { + @Override + ChangedLinesComputer newChangedLinesComputer(Path rootBaseDir, Set changedFiles) { + throw new IllegalStateException("crash"); + } + }; + assertThat(scmProvider.branchChangedLines("b1", b1, Collections.emptySet())).isNull(); + } + + @Test + public void forkDate_returns_null_if_no_fork_found() { + assertThat(new SvnScmProvider(config, new SvnBlameCommand(config), findFork).forkDate("branch", Paths.get(""))).isNull(); + } + + @Test + public void forkDate_returns_instant_if_fork_found() throws SVNException { + Path rootBaseDir = Paths.get(""); + String referenceBranch = "branch"; + Instant forkDate = Instant.ofEpochMilli(123456789L); + SvnScmProvider provider = new SvnScmProvider(config, new SvnBlameCommand(config), findFork); + when(findFork.findDate(rootBaseDir, referenceBranch)).thenReturn(forkDate); + + assertThat(provider.forkDate(referenceBranch, rootBaseDir)).isEqualTo(forkDate); + } + + @Test + public void forkDate_returns_null_if_exception_occurs() throws SVNException { + Path rootBaseDir = Paths.get(""); + String referenceBranch = "branch"; + SvnScmProvider provider = new SvnScmProvider(config, new SvnBlameCommand(config), findFork); + when(findFork.findDate(rootBaseDir, referenceBranch)).thenThrow(new SVNCancelException()); + + assertThat(provider.forkDate(referenceBranch, rootBaseDir)).isNull(); + } + + @Test + public void computeChangedPaths_should_not_crash_when_getRepositoryRootURL_getPath_is_empty() throws SVNException { + // verify assumptions about what SVNKit returns as svn root path for urls like http://svnserver/ + assertThat(SVNURL.parseURIEncoded("http://svnserver/").getPath()).isEmpty(); + assertThat(SVNURL.parseURIEncoded("http://svnserver").getPath()).isEmpty(); + + SVNClientManager svnClientManagerMock = mock(SVNClientManager.class); + + SVNWCClient svnwcClientMock = mock(SVNWCClient.class); + when(svnClientManagerMock.getWCClient()).thenReturn(svnwcClientMock); + + SVNLogClient svnLogClient = mock(SVNLogClient.class); + when(svnClientManagerMock.getLogClient()).thenReturn(svnLogClient); + + SVNInfo svnInfoMock = mock(SVNInfo.class); + when(svnwcClientMock.doInfo(any(), any())).thenReturn(svnInfoMock); + + // Simulate repository root on /, SVNKIT then returns an repository root url WITHOUT / at the end. + when(svnInfoMock.getRepositoryRootURL()).thenReturn(SVNURL.parseURIEncoded("http://svnserver")); + when(svnInfoMock.getURL()).thenReturn(SVNURL.parseURIEncoded("http://svnserver/myproject/trunk/")); + + assertThat(SvnScmProvider.computeChangedPaths(Paths.get("/"), svnClientManagerMock)).isEmpty(); + } + + private void createAndCommitFile(Path worktree, String filename, String content) throws IOException, SVNException { + svnTester.createFile(worktree, filename, content); + svnTester.add(worktree, filename); + svnTester.commit(worktree); + } + + private void createAndCommitFile(Path worktree, String filename) throws IOException, SVNException { + createAndCommitFile(worktree, filename, filename + "\n"); + } + + private void appendToAndCommitFile(Path worktree, String filename) throws IOException, SVNException { + svnTester.appendToFile(worktree, filename); + svnTester.commit(worktree); + } + + private void deleteAndCommitFile(Path worktree, String filename) throws IOException, SVNException { + svnTester.deleteFile(worktree, filename); + svnTester.commit(worktree); + } + + private void svnCopyAndCommitFile(Path worktree, String src, String dst) throws SVNException { + svnTester.copy(worktree, src, dst); + svnTester.commit(worktree); + } + + private SvnScmProvider newScmProvider() { + return new SvnScmProvider(config, new SvnBlameCommand(config), findFork); + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmSupportTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmSupportTest.java new file mode 100644 index 00000000000..33c3bd37ac9 --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnScmSupportTest.java @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.sonar.scm.svn.SvnScmSupport.newSvnClientManager; + +public class SvnScmSupportTest { + @Test + public void getExtensions() { + assertThat(SvnScmSupport.getObjects()).isNotEmpty(); + } + + @Test + public void newSvnClientManager_with_auth() { + SvnConfiguration config = mock(SvnConfiguration.class); + when(config.password()).thenReturn("password"); + when(config.passPhrase()).thenReturn("passPhrase"); + assertThat(newSvnClientManager(config)).isNotNull(); + } + + @Test + public void newSvnClientManager_without_auth() { + SvnConfiguration config = mock(SvnConfiguration.class); + assertThat(config.password()).isNull(); + assertThat(config.passPhrase()).isNull(); + assertThat(newSvnClientManager(config)).isNotNull(); + } + +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTester.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTester.java new file mode 100644 index 00000000000..ec00ca50b3c --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTester.java @@ -0,0 +1,135 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; +import org.tmatesoft.svn.core.SVNDepth; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.io.SVNRepositoryFactory; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.SVNCopyClient; +import org.tmatesoft.svn.core.wc.SVNCopySource; +import org.tmatesoft.svn.core.wc.SVNRevision; +import org.tmatesoft.svn.core.wc.SVNUpdateClient; +import org.tmatesoft.svn.core.wc2.SvnList; +import org.tmatesoft.svn.core.wc2.SvnOperationFactory; +import org.tmatesoft.svn.core.wc2.SvnRemoteMkDir; +import org.tmatesoft.svn.core.wc2.SvnTarget; + +public class SvnTester { + private final SVNClientManager manager = SVNClientManager.newInstance(new SvnOperationFactory()); + + private final SVNURL localRepository; + + public SvnTester(Path root) throws SVNException, IOException { + localRepository = SVNRepositoryFactory.createLocalRepository(root.toFile(), false, false); + mkdir("trunk"); + mkdir("branches"); + } + + private void mkdir(String relpath) throws IOException, SVNException { + SvnRemoteMkDir remoteMkDir = manager.getOperationFactory().createRemoteMkDir(); + remoteMkDir.addTarget(SvnTarget.fromURL(localRepository.appendPath(relpath, false))); + remoteMkDir.run(); + } + + public void createBranch(String branchName) throws IOException, SVNException { + SVNCopyClient copyClient = manager.getCopyClient(); + SVNCopySource source = new SVNCopySource(SVNRevision.HEAD, SVNRevision.HEAD, localRepository.appendPath("trunk", false)); + copyClient.doCopy(new SVNCopySource[] {source}, localRepository.appendPath("branches/" + branchName, false), false, false, true, "Create branch", null); + } + + public void createBranch(String branchSource, String branchName) throws IOException, SVNException { + SVNCopyClient copyClient = manager.getCopyClient(); + SVNCopySource source = new SVNCopySource(SVNRevision.HEAD, SVNRevision.HEAD, localRepository.appendPath(branchSource, false)); + copyClient.doCopy(new SVNCopySource[] {source}, localRepository.appendPath("branches/" + branchName, false), false, false, true, "Create branch", null); + } + + public void checkout(Path worktree, String path) throws SVNException { + SVNUpdateClient updateClient = manager.getUpdateClient(); + updateClient.doCheckout(localRepository.appendPath(path, false), + worktree.toFile(), null, null, SVNDepth.INFINITY, false); + } + + public void add(Path worktree, String filename) throws SVNException { + manager.getWCClient().doAdd(worktree.resolve(filename).toFile(), true, false, false, SVNDepth.INFINITY, false, false, true); + } + + public void copy(Path worktree, String src, String dst) throws SVNException { + SVNCopyClient copyClient = manager.getCopyClient(); + SVNCopySource source = new SVNCopySource(SVNRevision.HEAD, SVNRevision.HEAD, worktree.resolve(src).toFile()); + copyClient.doCopy(new SVNCopySource[]{source}, worktree.resolve(dst).toFile(), false, false, true); + } + + public void commit(Path worktree) throws SVNException { + manager.getCommitClient().doCommit(new File[] {worktree.toFile()}, false, "commit " + worktree, null, null, false, false, SVNDepth.INFINITY); + } + + public void update(Path worktree) throws SVNException { + manager.getUpdateClient().doUpdate(new File[] {worktree.toFile()}, SVNRevision.HEAD, SVNDepth.INFINITY, false, false); + } + + public Collection list(String... paths) throws SVNException { + Set results = new HashSet<>(); + + SvnList list = manager.getOperationFactory().createList(); + if (paths.length == 0) { + list.addTarget(SvnTarget.fromURL(localRepository)); + } else { + for (String path : paths) { + list.addTarget(SvnTarget.fromURL(localRepository.appendPath(path, false))); + } + } + list.setDepth(SVNDepth.INFINITY); + list.setReceiver((svnTarget, svnDirEntry) -> { + String path = svnDirEntry.getRelativePath(); + if (!path.isEmpty()) { + results.add(path); + } + }); + list.run(); + + return results; + } + + public void createFile(Path worktree, String filename, String content) throws IOException { + Files.write(worktree.resolve(filename), content.getBytes()); + } + + public void createFile(Path worktree, String filename) throws IOException { + createFile(worktree, filename, filename + "\n"); + } + + public void appendToFile(Path worktree, String filename) throws IOException { + Files.write(worktree.resolve(filename), (filename + "\n").getBytes(), StandardOpenOption.APPEND); + } + + public void deleteFile(Path worktree, String filename) throws SVNException { + manager.getWCClient().doDelete(worktree.resolve(filename).toFile(), false, false); + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTesterTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTesterTest.java new file mode 100644 index 00000000000..0740bb3960f --- /dev/null +++ b/sonar-scanner-engine/src/test/java/org/sonar/scm/svn/SvnTesterTest.java @@ -0,0 +1,68 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.scm.svn; + +import java.io.IOException; +import java.nio.file.Path; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tmatesoft.svn.core.SVNException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SvnTesterTest { + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + private SvnTester tester; + + @Before + public void before() throws IOException, SVNException { + tester = new SvnTester(temp.newFolder().toPath()); + } + + @Test + public void test_init() throws SVNException { + assertThat(tester.list()).containsExactlyInAnyOrder("trunk", "branches"); + } + + @Test + public void test_add_and_commit() throws IOException, SVNException { + assertThat(tester.list("trunk")).isEmpty(); + + Path worktree = temp.newFolder().toPath(); + tester.checkout(worktree, "trunk"); + tester.createFile(worktree, "file1"); + + tester.add(worktree, "file1"); + tester.commit(worktree); + + assertThat(tester.list("trunk")).containsOnly("file1"); + } + + @Test + public void test_createBranch() throws IOException, SVNException { + tester.createBranch("b1"); + assertThat(tester.list()).containsExactlyInAnyOrder("trunk", "branches", "branches/b1"); + assertThat(tester.list("branches")).containsOnly("b1"); + } +} diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn-with-merge.zip new file mode 100644 index 00000000000..d3c3ee5fa1e Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn-with-merge.zip differ diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn.zip new file mode 100644 index 00000000000..291d4fb86f7 Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.6/repo-svn.zip differ diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn-with-merge.zip new file mode 100644 index 00000000000..d3c3ee5fa1e Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn-with-merge.zip differ diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn.zip new file mode 100644 index 00000000000..291d4fb86f7 Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.7/repo-svn.zip differ diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn-with-merge.zip new file mode 100644 index 00000000000..852a05e4574 Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn-with-merge.zip differ diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn.zip new file mode 100644 index 00000000000..f31352c5e03 Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.8/repo-svn.zip differ diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn-with-merge.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn-with-merge.zip new file mode 100644 index 00000000000..ca672fd6681 Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn-with-merge.zip differ diff --git a/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn.zip b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn.zip new file mode 100644 index 00000000000..39b241cd698 Binary files /dev/null and b/sonar-scanner-engine/src/test/resources/org/sonar/scm/svn/test-repos/1.9/repo-svn.zip differ