|
| 1 | +/** |
| 2 | + * Copyright 2023 Open Text. |
| 3 | + * |
| 4 | + * The only warranties for products and services of Open Text |
| 5 | + * and its affiliates and licensors ("Open Text") are as may |
| 6 | + * be set forth in the express warranty statements accompanying |
| 7 | + * such products and services. Nothing herein should be construed |
| 8 | + * as constituting an additional warranty. Open Text shall not be |
| 9 | + * liable for technical or editorial errors or omissions contained |
| 10 | + * herein. The information contained herein is subject to change |
| 11 | + * without notice. |
| 12 | + */ |
| 13 | +package com.fortify.cli.common.util; |
| 14 | + |
| 15 | +import java.io.File; |
| 16 | +import java.nio.file.Files; |
| 17 | +import java.nio.file.Path; |
| 18 | +import java.util.Collection; |
| 19 | +import java.util.HashMap; |
| 20 | +import java.util.Map; |
| 21 | +import java.util.concurrent.atomic.AtomicReference; |
| 22 | +import java.util.stream.Stream; |
| 23 | + |
| 24 | +import org.slf4j.Logger; |
| 25 | +import org.slf4j.LoggerFactory; |
| 26 | + |
| 27 | +import com.formkiq.graalvm.annotations.Reflectable; |
| 28 | + |
| 29 | +import lombok.Builder; |
| 30 | +import lombok.Getter; |
| 31 | +import lombok.RequiredArgsConstructor; |
| 32 | +import lombok.SneakyThrows; |
| 33 | + |
| 34 | +/** |
| 35 | + * <p>Due to the way that Fortify packages and scans code, source file paths returned by FoD and SSC |
| 36 | + * may not exactly match original source file paths. In some cases, a leading path may be added |
| 37 | + * by Fortify, like /scancentral12345678/work/..., whereas in other cases, leading paths may be |
| 38 | + * stripped, like removing src/main/java from a path like src/main/java/my/pkg/Class.java</p> |
| 39 | + * |
| 40 | + * <p>This may lead to user confusion, and may cause issues when importing reports generated by |
| 41 | + * fcli actions like gitlab-sast-report or github-sast-report into the respective 3rd-party systems, |
| 42 | + * as these systems will then be unable to match the file path on which an issue is being reported |
| 43 | + * against an actual repository path.</p> |
| 44 | + * |
| 45 | + * <p>To work around these issues, this class attempts to map Fortify-provided issue source file |
| 46 | + * paths against local file system paths relative to a given source code directory, basically |
| 47 | + * finding the longest matching path suffix.</p> |
| 48 | + * |
| 49 | + * @author Ruud Senden |
| 50 | + */ |
| 51 | +// TODO Also see TODO comments in IssueSourceFileResolverTest |
| 52 | +// TODO How to handle absolute paths? Always return as-is (or null if not existing and OnNoMatch.NULL)? Or try to resolve against sourcePath? |
| 53 | +// TODO How to handle drive letters (with either absolute or relative path)? |
| 54 | +@Builder @Reflectable |
| 55 | +public class IssueSourceFileResolver { |
| 56 | + private static final Logger LOG = LoggerFactory.getLogger(IssueSourceFileResolver.class); |
| 57 | + @Getter private final Path sourcePath; |
| 58 | + @Builder.Default private final OnNoMatch onNoMatch = OnNoMatch.ORIGINAL; |
| 59 | + @Builder.Default private final FileSeparator separatorOnReturn = FileSeparator.LINUX; |
| 60 | + private final AtomicReference<SourceFileIndex> indexReference = new AtomicReference<>(); |
| 61 | + |
| 62 | + /** |
| 63 | + * Find the given {@link Path} in the configured source path. Based on the Fortify behavior |
| 64 | + * described in the class description, if the configured source path contains a single file |
| 65 | + * <code>src/main/java/com/fortify/X.java</code>, this method will return a relative {@link Path} |
| 66 | + * representing <code>src/main/java/com/fortify/X.java</code> for each of the following input paths: |
| 67 | + * <ul> |
| 68 | + * <li><code>scancentral123/work/src/main/java/com/fortify/X.java</code></li> |
| 69 | + * <li><code>any/leading/dir/src/main/java/com/fortify/X.java</code></li> |
| 70 | + * <li><code>src/main/java/com/fortify/X.java</code></li> |
| 71 | + * <li><code>main/java/com/fortify/X.java</code></li> |
| 72 | + * <li><code>java/com/fortify/X.java</code></li> |
| 73 | + * <li><code>com/fortify/X.java</code></li> |
| 74 | + * <li><code>fortify/X.java</code></li> |
| 75 | + * <li><code>X.java</code></li> |
| 76 | + * </ul> |
| 77 | + * Any other input path will be considered a non-matching path, in which case either the given |
| 78 | + * path or <code>null</code> will be returned, based on the configured {@link OnNoMatch} setting. |
| 79 | + * In particular, note that if the given path includes a leading directory, it will only match |
| 80 | + * if any of its sub-paths match the full relative source path. With the example above, this |
| 81 | + * means that <code>scancentral123/work/com/fortify/X.java</code> will be considered non-matching, |
| 82 | + * as it lacks the <code>src/main/java</code> path. |
| 83 | + */ |
| 84 | + public final Path resolve(Path issuePath) { |
| 85 | + var result = indexReference.updateAndGet(this::createIndexIfNull).resolve(issuePath); |
| 86 | + if ( result!=null && LOG.isTraceEnabled() ) { LOG.trace("Resolved issue path {} to source path {}", issuePath, result); } |
| 87 | + if ( result==null && onNoMatch==OnNoMatch.ORIGINAL ) { |
| 88 | + result = issuePath; |
| 89 | + } |
| 90 | + return result; |
| 91 | + } |
| 92 | + |
| 93 | + /** |
| 94 | + * This is the {@link String}-based variant of {@link #resolve(Path)}: |
| 95 | + * <ol> |
| 96 | + * <li>Convert the given {@link String} into a {@link Path} instance</li> |
| 97 | + * <li>Call the {@link #resolve(Path)} method to find this path in the configured source path</li> |
| 98 | + * <li>Convert the {@link Path} instance returned by {@link #resolve(Path)} into a {@link String}, |
| 99 | + * using the configured {@link #separatorOnReturn} file separator.</li> |
| 100 | + * </ol> |
| 101 | + */ |
| 102 | + public final String resolve(String issuePath) { |
| 103 | + return FileUtils.pathToString(resolve(Path.of(issuePath)), separatorOnReturn.getSeparatorChar()); |
| 104 | + } |
| 105 | + |
| 106 | + private final SourceFileIndex createIndexIfNull(SourceFileIndex index) { |
| 107 | + if ( index==null ) { |
| 108 | + index = new SourceFileIndex(sourcePath); |
| 109 | + } |
| 110 | + return index; |
| 111 | + } |
| 112 | + |
| 113 | + public static enum OnNoMatch { |
| 114 | + NULL, ORIGINAL |
| 115 | + } |
| 116 | + |
| 117 | + @RequiredArgsConstructor |
| 118 | + public static enum FileSeparator { |
| 119 | + LINUX('/'), WINDOWS('\\'), PLATFORM(File.separatorChar); |
| 120 | + |
| 121 | + @Getter private final char separatorChar; |
| 122 | + } |
| 123 | + |
| 124 | + private static final class SourceFileIndex { |
| 125 | + /** Full relative paths, like src/main/java/com/fortify/X.java */ |
| 126 | + private final Map<String,Path> fullRelativePathsIndex; |
| 127 | + /** Full and partial relative paths, like src/main/java/com/fortify/X.java, com/fortify/X.java, X.java */ |
| 128 | + private final Map<String,Path> fullAndPartialRelativePathsIndex; |
| 129 | + |
| 130 | + private SourceFileIndex(Path sourcePath) { |
| 131 | + this.fullRelativePathsIndex = createFullRelativePathsIndex(sourcePath); |
| 132 | + this.fullAndPartialRelativePathsIndex = createFullAndPartialRelativePathsIndex(fullRelativePathsIndex.values()); |
| 133 | + } |
| 134 | + |
| 135 | + /** |
| 136 | + * <p>This method first tries to match the given path against {@link #fullAndPartialRelativePathsIndex}, which, given a |
| 137 | + * source path <code>src/main/java/com/fortify/X.java</code>, will match each of the following input paths: |
| 138 | + * <code>X.java</code>, <code>com/fortify/X.java</code>, and <code>src/main/java/com/fortify/X.java</code>. This handles |
| 139 | + * situations where Fortify strips leading directories.</p> |
| 140 | + * |
| 141 | + * <p>If no match is found in {@link #fullAndPartialRelativePathsIndex}, this method will then attempt to match any of |
| 142 | + * the sub-paths in the given path against {@link #fullRelativePathsIndex}, which, given a source path |
| 143 | + * <code>src/main/java/com/fortify/X.java</code>, will match an input path like |
| 144 | + * <code>any/leading/dir/src/main/java/com/fortify/X.java</code>, but not <code>any/leading/dir/com/fortify/X.java</code>.</p> |
| 145 | + */ |
| 146 | + protected final Path resolve(Path path) { |
| 147 | + var normalizedPath = path.normalize(); |
| 148 | + var result = fullAndPartialRelativePathsIndex.get(pathToString(normalizedPath)); |
| 149 | + return result!=null ? result : resolveSubPathFromFullRelativePathsIndex(path); |
| 150 | + } |
| 151 | + |
| 152 | + private Path resolveSubPathFromFullRelativePathsIndex(Path normalizedPath) { |
| 153 | + var result = fullRelativePathsIndex.get(pathToString(normalizedPath)); |
| 154 | + if ( result==null ) { |
| 155 | + var nameCount = normalizedPath.getNameCount(); |
| 156 | + if ( nameCount > 1 ) { |
| 157 | + return resolveSubPathFromFullRelativePathsIndex(normalizedPath.subpath(1, nameCount)); |
| 158 | + } |
| 159 | + } |
| 160 | + return result; |
| 161 | + } |
| 162 | + |
| 163 | + @SneakyThrows |
| 164 | + private static final Map<String, Path> createFullRelativePathsIndex(Path sourcePath) { |
| 165 | + var result = new HashMap<String, Path>(); |
| 166 | + if ( sourcePath!=null && Files.isDirectory(sourcePath) ) { |
| 167 | + var normalizedSourcePath = sourcePath.normalize(); |
| 168 | + try (Stream<Path> stream = Files.walk(normalizedSourcePath)) { |
| 169 | + stream.filter(Files::isRegularFile) |
| 170 | + .forEach(p->addPathToFullRelativePathsIndex(result, normalizedSourcePath.relativize(p.normalize()))); |
| 171 | + } |
| 172 | + } |
| 173 | + return result; |
| 174 | + } |
| 175 | + |
| 176 | + private static final void addPathToFullRelativePathsIndex(Map<String, Path> result, Path fullRelativePath) { |
| 177 | + if ( LOG.isTraceEnabled() ) { LOG.trace("Adding source path {} to index", fullRelativePath); } |
| 178 | + result.put(pathToString(fullRelativePath), fullRelativePath); |
| 179 | + } |
| 180 | + |
| 181 | + private static final Map<String, Path> createFullAndPartialRelativePathsIndex(Collection<Path> fullRelativePaths) { |
| 182 | + var result = new HashMap<String, Path>(); |
| 183 | + fullRelativePaths.forEach(p->addPathToFullAndPartialRelativePathsIndex(result, p, p)); |
| 184 | + return result; |
| 185 | + } |
| 186 | + |
| 187 | + private static final void addPathToFullAndPartialRelativePathsIndex(Map<String, Path> index, Path fullRelativePath, Path partialRelativePath) { |
| 188 | + index.put(pathToString(partialRelativePath), fullRelativePath); |
| 189 | + var nameCount = partialRelativePath.getNameCount(); |
| 190 | + if ( nameCount > 1 ) { |
| 191 | + addPathToFullAndPartialRelativePathsIndex(index, fullRelativePath, partialRelativePath.subpath(1, nameCount)); |
| 192 | + } |
| 193 | + } |
| 194 | + |
| 195 | + private static final String pathToString(Path normalizedPath) { |
| 196 | + return FileUtils.pathToString(normalizedPath, '/'); |
| 197 | + } |
| 198 | + } |
| 199 | +} |
0 commit comments