| // Copyright 2019 The Bazel Authors. All rights reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.devtools.build.lib.bazel.repository; |
| |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| |
| import com.github.difflib.UnifiedDiffUtils; |
| import com.github.difflib.patch.AbstractDelta; |
| import com.github.difflib.patch.ChangeDelta; |
| import com.github.difflib.patch.Chunk; |
| import com.github.difflib.patch.DeleteDelta; |
| import com.github.difflib.patch.InsertDelta; |
| import com.github.difflib.patch.Patch; |
| import com.github.difflib.patch.PatchFailedException; |
| import com.google.common.base.Preconditions; |
| import com.google.common.base.Splitter; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.Iterables; |
| import com.google.common.collect.Lists; |
| import com.google.devtools.build.lib.vfs.FileSystemUtils; |
| import com.google.devtools.build.lib.vfs.Path; |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import javax.annotation.Nullable; |
| |
| /** Implementation of native patch. */ |
| public class PatchUtil { |
| |
| private static final Pattern CHUNK_HEADER_RE = |
| Pattern.compile("^@@\\s+-(?:(\\d+)(?:,(\\d+))?)\\s+\\+(?:(\\d+)(?:,(\\d+))?)\\s+@@$"); |
| |
| /** The possible results of ChunkHeader.check. */ |
| public enum Result { |
| COMPLETE, // The entire chunk is read |
| CONTINUE, // Should continue reading the chunk |
| ERROR, // The chunk body doesn't match the chunk header's size description |
| } |
| |
| private static class ChunkHeader { |
| private final int oldSize; |
| private final int newSize; |
| |
| public Result check(int oldLineCnt, int newLineCnt) { |
| if (oldLineCnt == oldSize && newLineCnt == newSize) { |
| return Result.COMPLETE; |
| } |
| if (oldLineCnt <= oldSize && newLineCnt <= newSize) { |
| return Result.CONTINUE; |
| } |
| return Result.ERROR; |
| } |
| |
| ChunkHeader(String header) throws PatchFailedException { |
| Matcher m = CHUNK_HEADER_RE.matcher(header); |
| if (m.find()) { |
| String size; |
| size = m.group(2); |
| oldSize = (size == null) ? 1 : Integer.parseInt(size); |
| size = m.group(4); |
| newSize = (size == null) ? 1 : Integer.parseInt(size); |
| } else { |
| throw new PatchFailedException("Wrong chunk header: " + header); |
| } |
| } |
| } |
| |
| // Sometimes the line number in patch file is not completely correct, but we might still be able |
| // to find a content match with an offset. |
| private static List<String> applyOffsetPatchTo(Patch<String> patch, ImmutableList<String> target) |
| throws PatchFailedException { |
| List<AbstractDelta<String>> deltas = patch.getDeltas(); |
| List<String> result = new ArrayList<>(target); |
| for (AbstractDelta<String> item : Lists.reverse(deltas)) { |
| AbstractDelta<String> delta = item; |
| applyTo(delta, result); |
| } |
| |
| return result; |
| } |
| |
| /** |
| * This function first tries to apply the Delta without any offset, if that fails, then it tries |
| * to apply the Delta with an offset, starting from 1, up to the total lines in the original |
| * content. For every offset, we try both forwards and backwards. |
| */ |
| private static void applyTo(AbstractDelta<String> delta, List<String> result) |
| throws PatchFailedException { |
| PatchFailedException e = applyDelta(delta, result); |
| if (e == null) { |
| return; |
| } |
| |
| Chunk<String> original = delta.getSource(); |
| Chunk<String> revised = delta.getTarget(); |
| int[] direction = {1, -1}; |
| int maxOffset = result.size(); |
| for (int i = 1; i < maxOffset; i++) { |
| for (int j = 0; j < 2; j++) { |
| int offset = i * direction[j]; |
| if (offset + original.getPosition() < 0 || offset + revised.getPosition() < 0) { |
| continue; |
| } |
| Chunk<String> source = new Chunk<>(original.getPosition() + offset, original.getLines()); |
| Chunk<String> target = new Chunk<>(revised.getPosition() + offset, revised.getLines()); |
| AbstractDelta<String> newDelta = null; |
| switch (delta.getType()) { |
| case CHANGE: |
| newDelta = new ChangeDelta<>(source, target); |
| break; |
| case INSERT: |
| newDelta = new InsertDelta<>(source, target); |
| break; |
| case DELETE: |
| newDelta = new DeleteDelta<>(source, target); |
| break; |
| case EQUAL: |
| } |
| PatchFailedException exception = null; |
| if (newDelta != null) { |
| exception = applyDelta(newDelta, result); |
| } |
| if (exception == null) { |
| return; |
| } |
| } |
| } |
| |
| throw e; |
| } |
| |
| @Nullable |
| private static PatchFailedException applyDelta(AbstractDelta<String> delta, List<String> result) { |
| try { |
| delta.applyTo(result); |
| return null; |
| } catch (PatchFailedException e) { |
| String msg = |
| String.join( |
| "\n", |
| "**Original Position**: " + (delta.getSource().getPosition() + 1) + "\n", |
| "**Original Content**:", |
| String.join("\n", delta.getSource().getLines()) + "\n", |
| "**Revised Content**:", |
| String.join("\n", delta.getTarget().getLines()) + "\n"); |
| return new PatchFailedException(e.getMessage() + "\n" + msg); |
| } |
| } |
| |
| private enum LineType { |
| OLD_FILE, |
| NEW_FILE, |
| CHUNK_HEAD, |
| CHUNK_ADD, |
| CHUNK_DEL, |
| CHUNK_EQL, |
| GIT_HEADER, |
| RENAME_FROM, |
| RENAME_TO, |
| NEW_MODE, |
| NEW_FILE_MODE, |
| OTHER_GIT_LINE, |
| UNKNOWN |
| } |
| |
| private static final String[] GIT_LINE_PREFIXES = { |
| "old mode ", |
| "new mode ", |
| "deleted file mode ", |
| "new file mode ", |
| "copy from ", |
| "copy to ", |
| "rename old ", |
| "rename new ", |
| "similarity index ", |
| "dissimilarity index ", |
| "index " |
| }; |
| |
| private static LineType getLineType(String line, boolean isReadingChunk, boolean isGitDiff) { |
| if (isReadingChunk) { |
| if (line.startsWith("+")) { |
| return LineType.CHUNK_ADD; |
| } |
| if (line.startsWith("-")) { |
| return LineType.CHUNK_DEL; |
| } |
| if (line.startsWith(" ") || line.isEmpty()) { |
| return LineType.CHUNK_EQL; |
| } |
| } else { |
| if (line.startsWith("--- ")) { |
| return LineType.OLD_FILE; |
| } |
| if (line.startsWith("+++ ")) { |
| return LineType.NEW_FILE; |
| } |
| if (line.startsWith("diff --git ")) { |
| return LineType.GIT_HEADER; |
| } |
| if (isGitDiff) { |
| // Only recognize the following when we saw "diff --git " before. |
| if (line.startsWith("rename from ")) { |
| return LineType.RENAME_FROM; |
| } |
| if (line.startsWith("rename to ")) { |
| return LineType.RENAME_TO; |
| } |
| if (line.startsWith("new mode ")) { |
| return LineType.NEW_MODE; |
| } |
| if (line.startsWith("new file mode ")) { |
| return LineType.NEW_FILE_MODE; |
| } |
| for (String prefix : GIT_LINE_PREFIXES) { |
| if (line.startsWith(prefix)) { |
| return LineType.OTHER_GIT_LINE; |
| } |
| } |
| } |
| } |
| if (line.startsWith("@@") && line.lastIndexOf("@@") != 0) { |
| int pos = line.indexOf("@@", 2); |
| Matcher m = CHUNK_HEADER_RE.matcher(line.substring(0, pos + 2)); |
| if (m.find()) { |
| return LineType.CHUNK_HEAD; |
| } |
| } |
| return LineType.UNKNOWN; |
| } |
| |
| private static ImmutableList<String> readFile(Path file) throws IOException { |
| return FileSystemUtils.readLines(file, UTF_8); |
| } |
| |
| private static void writeFile(Path file, List<String> content) throws IOException { |
| FileSystemUtils.writeLinesAs(file, UTF_8, content); |
| } |
| |
| private static boolean getReadPermission(int permission) { |
| // Parse read permission from posix file permission notation |
| return (permission & 4) == 4; |
| } |
| |
| private static boolean getWritePermission(int permission) { |
| // Parse write permission from posix file permission notation |
| return (permission & 2) == 2; |
| } |
| |
| private static boolean getExecutablePermission(int permission) { |
| // Parse executable permission from posix file permission notation |
| return (permission & 1) == 1; |
| } |
| |
| private static int getFilePermissionValue(Path file) throws IOException { |
| return (file.isReadable() ? 4 : 0) |
| + (file.isWritable() ? 2 : 0) |
| + (file.isExecutable() ? 1 : 0); |
| } |
| |
| private static void setFilePermission(Path file, int permission) throws IOException { |
| file.setReadable(getReadPermission(permission)); |
| file.setWritable(getWritePermission(permission)); |
| file.setExecutable(getExecutablePermission(permission)); |
| } |
| |
| private static void applyPatchToFile( |
| Patch<String> patch, Path oldFile, Path newFile, boolean isRenaming, int filePermission) |
| throws IOException, PatchFailedException { |
| // The file we should read oldContent from. |
| Path inputFile = null; |
| if (oldFile != null && oldFile.exists()) { |
| inputFile = oldFile; |
| } else if (newFile != null && newFile.exists()) { |
| inputFile = newFile; |
| } |
| |
| ImmutableList<String> oldContent; |
| if (inputFile == null) { |
| oldContent = ImmutableList.of(); |
| } else { |
| oldContent = readFile(inputFile); |
| // Preserve old file permission if no explicit permission is set. |
| if (filePermission == -1) { |
| filePermission = getFilePermissionValue(inputFile); |
| } |
| } |
| |
| List<String> newContent = applyOffsetPatchTo(patch, oldContent); |
| |
| // The file we should write newContent to. |
| Path outputFile; |
| if (oldFile != null && oldFile.exists() && !isRenaming) { |
| outputFile = oldFile; |
| } else { |
| outputFile = newFile; |
| } |
| |
| // The old file should always change, therefore we can just delete the original file. |
| // If the output file name is the same as the old file, we'll just recreate it later. |
| if (oldFile != null) { |
| oldFile.delete(); |
| } |
| |
| // Does this patch look like deleting a file. |
| boolean isDeleteFile = newFile == null && newContent.isEmpty(); |
| |
| if (outputFile != null && !isDeleteFile) { |
| writeFile(outputFile, newContent); |
| if (filePermission != -1) { |
| setFilePermission(outputFile, filePermission); |
| } |
| } |
| } |
| |
| /** |
| * Strip a number of leading components from a path |
| * |
| * @param path the original path |
| * @param strip the number of leading components to strip |
| * @return The stripped path |
| */ |
| private static String stripPath(String path, int strip) { |
| int pos = 0; |
| while (pos < path.length() && strip > 0) { |
| if (path.charAt(pos) == '/') { |
| strip--; |
| } |
| pos++; |
| } |
| return path.substring(pos); |
| } |
| |
| /** |
| * Extract the file path from a patch line starting with "--- " or "+++ " Returns null if the path |
| * is /dev/null, otherwise returns the extracted path if succeeded or throw an exception if |
| * failed. |
| */ |
| @Nullable |
| private static String extractPath(String line, int strip, int loc) throws PatchFailedException { |
| // The line could look like: |
| // --- a/foo/bar.txt 2019-05-27 17:19:37.054593200 +0200 |
| // +++ b/foo/bar.txt 2019-05-27 17:19:37.054593200 +0200 |
| // If strip is 1, we want extract the file path as foo/bar.txt |
| Preconditions.checkArgument(line.startsWith("+++ ") || line.startsWith("--- ")); |
| line = Iterables.get(Splitter.on('\t').split(line), 0); |
| if (line.length() > 4) { |
| String path = line.substring(4).trim(); |
| if (path.equals("/dev/null")) { |
| return null; |
| } |
| path = stripPath(path, strip); |
| if (!path.isEmpty()) { |
| return path; |
| } |
| } |
| throw new PatchFailedException( |
| String.format( |
| "Cannot determine file name with strip = %d at line %d:\n%s", strip, loc, line)); |
| } |
| |
| @Nullable |
| private static Path getFilePath(String path, Path outputDirectory, int loc) |
| throws PatchFailedException { |
| if (path == null) { |
| return null; |
| } |
| Path filePath = outputDirectory.getRelative(path); |
| if (!filePath.startsWith(outputDirectory)) { |
| throw new PatchFailedException( |
| String.format( |
| "Cannot patch file outside of external repository (%s), file path = \"%s\" at line" |
| + " %d", |
| outputDirectory.getPathString(), path, loc)); |
| } |
| return filePath; |
| } |
| |
| private static void checkPatchContentIsComplete( |
| List<String> patchContent, ChunkHeader header, int oldLineCount, int newLineCount, int loc) |
| throws PatchFailedException { |
| // If the patchContent is not empty, it should have correct format. |
| if (!patchContent.isEmpty()) { |
| if (patchContent.size() < 2 |
| || !patchContent.get(0).startsWith("---") |
| || !patchContent.get(1).startsWith("+++")) { |
| throw new PatchFailedException( |
| String.format( |
| "The patch content must start with ---/+++ prelude lines at line %d.", loc)); |
| } |
| if (header == null) { |
| throw new PatchFailedException( |
| String.format( |
| "Looks like a unified diff at line %d, but no patch chunk was found.", loc)); |
| } |
| Result result = header.check(oldLineCount, newLineCount); |
| // result will never be Result.Error here because it would have been throw in previous |
| // line already. |
| if (result == Result.CONTINUE) { |
| throw new PatchFailedException( |
| String.format("Expecting more chunk line at line %d", loc + patchContent.size())); |
| } |
| } |
| } |
| |
| private static void checkFilesStatusForRenaming( |
| Path oldFile, Path newFile, String oldFileStr, String newFileStr, int loc) |
| throws PatchFailedException { |
| // If we're doing a renaming, |
| // old file should be specified and exists, |
| // new file should be specified but doesn't exist yet. |
| String oldFileError = ""; |
| String newFileError = ""; |
| if (oldFile == null) { |
| oldFileError = ", old file name (%s) is not specified"; |
| } else if (!oldFile.exists()) { |
| oldFileError = String.format(", old file name (%s) doesn't exist", oldFileStr); |
| } |
| if (newFile == null) { |
| newFileError = ", new file name is not specified"; |
| } else if (newFile.exists()) { |
| newFileError = String.format(", new file name (%s) already exists", newFileStr); |
| } |
| if (!oldFileError.isEmpty() || !newFileError.isEmpty()) { |
| throw new PatchFailedException( |
| String.format("Cannot rename file (near line %d)%s%s.", loc, oldFileError, newFileError)); |
| } |
| } |
| |
| private static void checkFilesStatusForPatching( |
| Patch<String> patch, |
| Path oldFile, |
| Path newFile, |
| String oldFileStr, |
| String newFileStr, |
| int loc) |
| throws PatchFailedException { |
| // At least one of oldFile or newFile should be specified. |
| if (oldFile == null && newFile == null) { |
| throw new PatchFailedException( |
| String.format( |
| "Wrong patch format near line %d, neither new file or old file are specified.", loc)); |
| } |
| |
| // Does this patch look like adding a new file. |
| boolean isAddFile = |
| patch.getDeltas().size() == 1 && patch.getDeltas().get(0).getSource().getLines().isEmpty(); |
| |
| // If this patch is not adding a new file, |
| // then either old file or new file should be specified and exists, |
| // if not we throw an error. |
| if (!isAddFile |
| && (oldFile == null || !oldFile.exists()) |
| && (newFile == null || !newFile.exists())) { |
| String oldFileError; |
| String newFileError; |
| if (oldFile == null) { |
| oldFileError = ", old file name (%s) is not specified"; |
| } else { |
| oldFileError = String.format(", old file name (%s) doesn't exist", oldFileStr); |
| } |
| if (newFile == null) { |
| newFileError = ", new file name is not specified"; |
| } else { |
| newFileError = String.format(", new file name (%s) doesn't exist", newFileStr); |
| } |
| throw new PatchFailedException( |
| String.format( |
| "Cannot find file to patch (near line %d)%s%s.", loc, oldFileError, newFileError)); |
| } |
| } |
| |
| /** |
| * Apply a patch file under a directory |
| * |
| * @param patchFile the patch file to apply |
| * @param strip the number of leading components to strip from file path in the patch file |
| * @param outputDirectory the repository directory to apply the patch file |
| */ |
| public static void apply(Path patchFile, int strip, Path outputDirectory) |
| throws IOException, PatchFailedException { |
| if (!patchFile.exists()) { |
| throw new PatchFailedException("Cannot find patch file: " + patchFile.getPathString()); |
| } |
| |
| boolean isGitDiff = false; |
| boolean hasRenameFrom = false; |
| boolean hasRenameTo = false; |
| boolean isReadingChunk = false; |
| List<String> patchContent = new ArrayList<>(); |
| ChunkHeader header = null; |
| String oldFileStr = null; |
| String newFileStr = null; |
| Path oldFile = null; |
| Path newFile = null; |
| int oldLineCount = 0; |
| int newLineCount = 0; |
| int filePermission = -1; |
| Result result; |
| |
| ImmutableList<String> patchFileLines = readFile(patchFile); |
| for (int i = 0; i <= patchFileLines.size(); i++) { |
| // Adding an extra line to make sure last chunk also gets applied. |
| String line = i < patchFileLines.size() ? patchFileLines.get(i) : "$"; |
| LineType type; |
| switch (type = getLineType(line, isReadingChunk, isGitDiff)) { |
| case OLD_FILE: |
| patchContent.add(line); |
| oldFileStr = extractPath(line, strip, i + 1); |
| oldFile = getFilePath(oldFileStr, outputDirectory, i + 1); |
| break; |
| case NEW_FILE: |
| patchContent.add(line); |
| newFileStr = extractPath(line, strip, i + 1); |
| newFile = getFilePath(newFileStr, outputDirectory, i + 1); |
| break; |
| case NEW_MODE: |
| case NEW_FILE_MODE: |
| // The line should look like: "new mode 100755" or "new file mode 100755" |
| // 7 is the file permission for owner, which is at index 12 or 17 |
| int index = type == LineType.NEW_MODE ? 12 : 17; |
| char c = line.charAt(index); |
| if (c < '0' || c > '7') { |
| throw new PatchFailedException( |
| "Wrong file mode format at line " + (i + 1) + ": " + line); |
| } |
| filePermission = Character.getNumericValue(c); |
| break; |
| case CHUNK_HEAD: |
| int pos = line.indexOf("@@", 2); |
| String headerStr = line.substring(0, pos + 2); |
| patchContent.add(headerStr); |
| header = new ChunkHeader(headerStr); |
| oldLineCount = 0; |
| newLineCount = 0; |
| isReadingChunk = true; |
| break; |
| case CHUNK_ADD: |
| newLineCount++; |
| patchContent.add(line); |
| result = header.check(oldLineCount, newLineCount); |
| if (result == Result.COMPLETE) { |
| isReadingChunk = false; |
| } else if (result == Result.ERROR) { |
| throw new PatchFailedException( |
| "Wrong chunk detected near line " |
| + (i + 1) |
| + ": " |
| + line |
| + ", does not expect an added line here."); |
| } |
| break; |
| case CHUNK_DEL: |
| oldLineCount++; |
| patchContent.add(line); |
| result = header.check(oldLineCount, newLineCount); |
| if (result == Result.COMPLETE) { |
| isReadingChunk = false; |
| } else if (result == Result.ERROR) { |
| throw new PatchFailedException( |
| "Wrong chunk detected near line " |
| + (i + 1) |
| + ": " |
| + line |
| + ", does not expect a deleted line here."); |
| } |
| break; |
| case CHUNK_EQL: |
| oldLineCount++; |
| newLineCount++; |
| patchContent.add(line); |
| result = header.check(oldLineCount, newLineCount); |
| if (result == Result.COMPLETE) { |
| isReadingChunk = false; |
| } else if (result == Result.ERROR) { |
| throw new PatchFailedException( |
| "Wrong chunk detected near line " |
| + (i + 1) |
| + ": " |
| + line |
| + ", does not expect a context line here."); |
| } |
| break; |
| case RENAME_FROM: |
| hasRenameFrom = true; |
| if (oldFileStr == null) { |
| // len("rename from ") == 12 |
| oldFileStr = line.substring(12).trim(); |
| if (oldFileStr.isEmpty()) { |
| throw new PatchFailedException( |
| String.format("Cannot determine file name from line %d:\n%s", i + 1, line)); |
| } |
| oldFile = getFilePath(oldFileStr, outputDirectory, i + 1); |
| } |
| break; |
| case RENAME_TO: |
| hasRenameTo = true; |
| if (newFileStr == null) { |
| // len("rename to ") == 10 |
| newFileStr = line.substring(10).trim(); |
| if (newFileStr.isEmpty()) { |
| throw new PatchFailedException( |
| String.format("Cannot determine file name from line %d:\n%s", i + 1, line)); |
| } |
| newFile = getFilePath(newFileStr, outputDirectory, i + 1); |
| } |
| break; |
| case OTHER_GIT_LINE: |
| break; |
| case GIT_HEADER: |
| case UNKNOWN: |
| // A git header line or an unknown line should trigger an action to apply collected |
| // patch content to a file. |
| |
| // Renaming is a git only format |
| boolean isRenaming = isGitDiff && hasRenameFrom && hasRenameTo; |
| |
| if (!patchContent.isEmpty() || isRenaming || filePermission != -1) { |
| // We collected something useful, let's do some checks before applying the patch. |
| int patchStartLocation = i + 1 - patchContent.size(); |
| |
| checkPatchContentIsComplete( |
| patchContent, header, oldLineCount, newLineCount, patchStartLocation); |
| |
| if (isRenaming) { |
| checkFilesStatusForRenaming( |
| oldFile, newFile, oldFileStr, newFileStr, patchStartLocation); |
| } |
| |
| Patch<String> patch = UnifiedDiffUtils.parseUnifiedDiff(patchContent); |
| checkFilesStatusForPatching( |
| patch, oldFile, newFile, oldFileStr, newFileStr, patchStartLocation); |
| |
| applyPatchToFile(patch, oldFile, newFile, isRenaming, filePermission); |
| } |
| |
| patchContent.clear(); |
| header = null; |
| oldFileStr = null; |
| newFileStr = null; |
| oldFile = null; |
| newFile = null; |
| filePermission = -1; |
| oldLineCount = 0; |
| newLineCount = 0; |
| isReadingChunk = false; |
| // If the new patch starts with "diff --git " then it's a git diff. |
| isGitDiff = type == LineType.GIT_HEADER; |
| if (isGitDiff) { |
| // In case there is no line starting with +++ and --- (file permission change), |
| // try to parse the file names from the line starting with "diff --git" |
| List<String> args = Splitter.on(' ').splitToList(line); |
| if (args.size() >= 4) { |
| oldFileStr = stripPath(args.get(2), strip); |
| if (!oldFileStr.isEmpty()) { |
| oldFile = getFilePath(oldFileStr, outputDirectory, i + 1); |
| } |
| newFileStr = stripPath(args.get(3), strip); |
| if (!newFileStr.isEmpty()) { |
| newFile = getFilePath(newFileStr, outputDirectory, i + 1); |
| } |
| } |
| } |
| hasRenameFrom = false; |
| hasRenameTo = false; |
| break; |
| } |
| } |
| } |
| |
| private PatchUtil() {} |
| } |