/* * Copyright (C) 2023, Google Inc. and others * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at * https://www.eclipse.org/org/documents/edl-v10.php. * * SPDX-License-Identifier: BSD-3-Clause */ package org.eclipse.jgit.patch; import static org.eclipse.jgit.diff.DiffEntry.ChangeType.ADD; import static org.eclipse.jgit.diff.DiffEntry.ChangeType.COPY; import static org.eclipse.jgit.diff.DiffEntry.ChangeType.DELETE; import static org.eclipse.jgit.diff.DiffEntry.ChangeType.MODIFY; import static org.eclipse.jgit.diff.DiffEntry.ChangeType.RENAME; import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.text.MessageFormat; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import java.util.zip.InflaterInputStream; import org.eclipse.jgit.annotations.Nullable; import org.eclipse.jgit.api.errors.FilterFailedException; import org.eclipse.jgit.api.errors.PatchFormatException; import org.eclipse.jgit.attributes.Attribute; import org.eclipse.jgit.attributes.Attributes; import org.eclipse.jgit.attributes.FilterCommand; import org.eclipse.jgit.attributes.FilterCommandRegistry; import org.eclipse.jgit.diff.DiffEntry.ChangeType; import org.eclipse.jgit.diff.RawText; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheBuilder; import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; import org.eclipse.jgit.dircache.DirCacheCheckout.StreamSupplier; import org.eclipse.jgit.dircache.DirCacheEntry; import org.eclipse.jgit.dircache.DirCacheIterator; import org.eclipse.jgit.errors.CorruptObjectException; import org.eclipse.jgit.errors.IndexWriteException; import org.eclipse.jgit.internal.JGitText; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ConfigConstants; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.CoreConfig.EolStreamType; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.FileModeCache; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectInserter; import org.eclipse.jgit.lib.ObjectLoader; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.patch.FileHeader.PatchType; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.treewalk.FileTreeIterator; import org.eclipse.jgit.treewalk.TreeWalk; import org.eclipse.jgit.treewalk.TreeWalk.OperationType; import org.eclipse.jgit.treewalk.WorkingTreeOptions; import org.eclipse.jgit.treewalk.filter.AndTreeFilter; import org.eclipse.jgit.treewalk.filter.NotIgnoredFilter; import org.eclipse.jgit.treewalk.filter.PathFilterGroup; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.FS.ExecutionResult; import org.eclipse.jgit.util.FileUtils; import org.eclipse.jgit.util.IO; import org.eclipse.jgit.util.LfsFactory; import org.eclipse.jgit.util.LfsFactory.LfsInputStream; import org.eclipse.jgit.util.RawParseUtils; import org.eclipse.jgit.util.StringUtils; import org.eclipse.jgit.util.SystemReader; import org.eclipse.jgit.util.TemporaryBuffer; import org.eclipse.jgit.util.TemporaryBuffer.LocalFile; import org.eclipse.jgit.util.io.BinaryDeltaInputStream; import org.eclipse.jgit.util.io.BinaryHunkInputStream; import org.eclipse.jgit.util.io.CountingOutputStream; import org.eclipse.jgit.util.io.EolStreamTypeUtil; import org.eclipse.jgit.util.sha1.SHA1; /** * Applies a patch to files and the index. *

* After instantiating, applyPatch() should be called once. *

* * @since 6.4 */ public class PatchApplier { private static final byte[] NO_EOL = "\\ No newline at end of file" //$NON-NLS-1$ .getBytes(StandardCharsets.US_ASCII); /** The tree before applying the patch. Only non-null for inCore operation. */ @Nullable private final RevTree beforeTree; private final Repository repo; private final ObjectInserter inserter; private final ObjectReader reader; private WorkingTreeOptions workingTreeOptions; private int inCoreSizeLimit; /** * @param repo * repository to apply the patch in */ public PatchApplier(Repository repo) { this.repo = repo; inserter = repo.newObjectInserter(); reader = inserter.newReader(); beforeTree = null; Config config = repo.getConfig(); workingTreeOptions = config.get(WorkingTreeOptions.KEY); inCoreSizeLimit = config.getInt(ConfigConstants.CONFIG_MERGE_SECTION, ConfigConstants.CONFIG_KEY_IN_CORE_LIMIT, 10 << 20); } /** * @param repo * repository to apply the patch in * @param beforeTree * ID of the tree to apply the patch in * @param oi * to be used for modifying objects */ public PatchApplier(Repository repo, RevTree beforeTree, ObjectInserter oi) { this.repo = repo; this.beforeTree = beforeTree; inserter = oi; reader = oi.newReader(); } /** * A wrapper for returning both the applied tree ID and the applied files * list, as well as file specific errors. * * @since 6.3 */ public static class Result { /** * A wrapper for a patch applying error that affects a given file. * * @since 6.6 */ // TODO(ms): rename this class in next major release @SuppressWarnings("JavaLangClash") public static class Error { private String msg; private String oldFileName; private @Nullable HunkHeader hh; private Error(String msg, String oldFileName, @Nullable HunkHeader hh) { this.msg = msg; this.oldFileName = oldFileName; this.hh = hh; } @Override public String toString() { if (hh != null) { return MessageFormat.format(JGitText.get().patchApplyErrorWithHunk, oldFileName, hh, msg); } return MessageFormat.format(JGitText.get().patchApplyErrorWithoutHunk, oldFileName, msg); } } private ObjectId treeId; private List paths; private List errors = new ArrayList<>(); /** * Get modified paths * * @return List of modified paths. */ public List getPaths() { return paths; } /** * Get tree ID * * @return The applied tree ID. */ public ObjectId getTreeId() { return treeId; } /** * Get errors * * @return Errors occurred while applying the patch. * * @since 6.6 */ public List getErrors() { return errors; } private void addError(String msg,String oldFileName, @Nullable HunkHeader hh) { errors.add(new Error(msg, oldFileName, hh)); } } /** * Applies the given patch * * @param patchInput * the patch to apply. * @return the result of the patch * @throws PatchFormatException * if the patch cannot be parsed * @throws IOException * if the patch read fails * @deprecated use {@link #applyPatch(Patch)} instead */ @Deprecated public Result applyPatch(InputStream patchInput) throws PatchFormatException, IOException { Patch p = new Patch(); try (InputStream inStream = patchInput) { p.parse(inStream); if (!p.getErrors().isEmpty()) { throw new PatchFormatException(p.getErrors()); } } return applyPatch(p); } /** * Applies the given patch * * @param p * the patch to apply. * @return the result of the patch * @throws IOException * if an IO error occurred * @since 6.6 */ public Result applyPatch(Patch p) throws IOException { Result result = new Result(); DirCache dirCache = inCore() ? DirCache.read(reader, beforeTree) : repo.lockDirCache(); FileModeCache directoryCache = new FileModeCache(repo); DirCacheBuilder dirCacheBuilder = dirCache.builder(); Set modifiedPaths = new HashSet<>(); for (FileHeader fh : p.getFiles()) { ChangeType type = fh.getChangeType(); File src = getFile(fh.getOldPath()); File dest = getFile(fh.getNewPath()); if (!verifyExistence(fh, src, dest, result)) { continue; } switch (type) { case ADD: { if (dest != null) { directoryCache.safeCreateParentDirectory(fh.getNewPath(), dest.getParentFile(), false); FileUtils.createNewFile(dest); } apply(fh.getNewPath(), dirCache, dirCacheBuilder, dest, fh, result); } break; case MODIFY: { apply(fh.getOldPath(), dirCache, dirCacheBuilder, src, fh, result); break; } case DELETE: { if (!inCore()) { if (!src.delete()) throw new IOException(MessageFormat.format( JGitText.get().cannotDeleteFile, src)); } break; } case RENAME: { if (!inCore()) { /* * this is odd: we rename the file on the FS, but * apply() will write a fresh stream anyway, which will * overwrite if there were hunks in the patch. */ directoryCache.safeCreateParentDirectory(fh.getNewPath(), dest.getParentFile(), false); FileUtils.rename(src, dest, StandardCopyOption.ATOMIC_MOVE); } String pathWithOriginalContent = inCore() ? fh.getOldPath() : fh.getNewPath(); apply(pathWithOriginalContent, dirCache, dirCacheBuilder, dest, fh, result); break; } case COPY: { if (!inCore()) { directoryCache.safeCreateParentDirectory(fh.getNewPath(), dest.getParentFile(), false); Files.copy(src.toPath(), dest.toPath()); } apply(fh.getOldPath(), dirCache, dirCacheBuilder, dest, fh, result); break; } } if (fh.getChangeType() != DELETE) modifiedPaths.add(fh.getNewPath()); if (fh.getChangeType() != COPY && fh.getChangeType() != ADD) modifiedPaths.add(fh.getOldPath()); } // We processed the patch. Now add things that weren't changed. for (int i = 0; i < dirCache.getEntryCount(); i++) { DirCacheEntry dce = dirCache.getEntry(i); if (!modifiedPaths.contains(dce.getPathString()) || dce.getStage() != DirCacheEntry.STAGE_0) dirCacheBuilder.add(dce); } if (inCore()) dirCacheBuilder.finish(); else if (!dirCacheBuilder.commit()) { throw new IndexWriteException(); } result.treeId = dirCache.writeTree(inserter); result.paths = modifiedPaths.stream().sorted() .collect(Collectors.toList()); return result; } private File getFile(String path) { return inCore() ? null : new File(repo.getWorkTree(), path); } /* returns null if the path is not found. */ @Nullable private TreeWalk getTreeWalkForFile(String path, DirCache cache) throws IOException { if (inCore()) { // Only this branch may return null. // TODO: it would be nice if we could return a TreeWalk at EOF // iso. null. return TreeWalk.forPath(repo, path, beforeTree); } TreeWalk walk = new TreeWalk(repo); // Use a TreeWalk with a DirCacheIterator to pick up the correct // clean/smudge filters. int cacheTreeIdx = walk.addTree(new DirCacheIterator(cache)); FileTreeIterator files = new FileTreeIterator(repo); if (FILE_TREE_INDEX != walk.addTree(files)) throw new IllegalStateException(); walk.setFilter(AndTreeFilter.create( PathFilterGroup.createFromStrings(path), new NotIgnoredFilter(FILE_TREE_INDEX))); walk.setOperationType(OperationType.CHECKIN_OP); walk.setRecursive(true); files.setDirCacheIterator(walk, cacheTreeIdx); return walk; } private boolean fileExists(String path, @Nullable File f) throws IOException { if (f != null) { return f.exists(); } return inCore() && TreeWalk.forPath(repo, path, beforeTree) != null; } private boolean verifyExistence(FileHeader fh, File src, File dest, Result result) throws IOException { boolean isValid = true; boolean srcShouldExist = List.of(MODIFY, DELETE, RENAME, COPY) .contains(fh.getChangeType()); boolean destShouldNotExist = List.of(ADD, RENAME, COPY) .contains(fh.getChangeType()); if (srcShouldExist != fileExists(fh.getOldPath(), src)) { result.addError(MessageFormat.format(srcShouldExist ? JGitText.get().applyPatchWithSourceOnNonExistentSource : JGitText .get().applyPatchWithoutSourceOnAlreadyExistingSource, fh.getPatchType()), fh.getOldPath(), null); isValid = false; } if (destShouldNotExist && fileExists(fh.getNewPath(), dest)) { result.addError(MessageFormat.format(JGitText .get().applyPatchWithCreationOverAlreadyExistingDestination, fh.getPatchType()), fh.getNewPath(), null); isValid = false; } if (srcShouldExist && !validGitPath(fh.getOldPath())) { result.addError(JGitText.get().applyPatchSourceInvalid, fh.getOldPath(), null); isValid = false; } if (destShouldNotExist && !validGitPath(fh.getNewPath())) { result.addError(JGitText.get().applyPatchDestInvalid, fh.getNewPath(), null); isValid = false; } return isValid; } private boolean validGitPath(String path) { try { SystemReader.getInstance().checkPath(path); return true; } catch (CorruptObjectException e) { return false; } } private static final int FILE_TREE_INDEX = 1; /** * Applies patch to a single file. * * @param pathWithOriginalContent * The path to use for the pre-image. Also determines CRLF and * smudge settings. * @param dirCache * Dircache to read existing data from. * @param dirCacheBuilder * Builder for Dircache to write new data to. * @param f * The file to update with new contents. Null for inCore usage. * @param fh * The patch header. * @param result * The patch application result. * @throws IOException * if an IO error occurred */ private void apply(String pathWithOriginalContent, DirCache dirCache, DirCacheBuilder dirCacheBuilder, @Nullable File f, FileHeader fh, Result result) throws IOException { if (PatchType.BINARY.equals(fh.getPatchType())) { // This patch type just says "something changed". We can't do // anything with that. // Maybe this should return an error code, though? return; } TreeWalk walk = getTreeWalkForFile(pathWithOriginalContent, dirCache); boolean loadedFromTreeWalk = false; // CR-LF handling is determined by whether the file or the patch // have CR-LF line endings. boolean convertCrLf = inCore() || needsCrLfConversion(f, fh); EolStreamType streamType = convertCrLf ? EolStreamType.TEXT_CRLF : EolStreamType.DIRECT; String smudgeFilterCommand = null; StreamSupplier fileStreamSupplier = null; ObjectId fileId = ObjectId.zeroId(); if (walk == null) { // For new files with inCore()==true, TreeWalk.forPath can be // null. Stay with defaults. } else if (inCore()) { fileId = walk.getObjectId(0); ObjectLoader loader = LfsFactory.getInstance() .applySmudgeFilter(repo, reader.open(fileId, OBJ_BLOB), null); byte[] data = loader.getBytes(); convertCrLf = RawText.isCrLfText(data); fileStreamSupplier = () -> new ByteArrayInputStream(data); streamType = convertCrLf ? EolStreamType.TEXT_CRLF : EolStreamType.DIRECT; smudgeFilterCommand = walk .getFilterCommand(Constants.ATTR_FILTER_TYPE_SMUDGE); loadedFromTreeWalk = true; } else if (walk.next()) { // If the file on disk has no newline characters, // convertCrLf will be false. In that case we want to honor the // normal git settings. streamType = convertCrLf ? EolStreamType.TEXT_CRLF : walk.getEolStreamType(OperationType.CHECKOUT_OP); smudgeFilterCommand = walk .getFilterCommand(Constants.ATTR_FILTER_TYPE_SMUDGE); FileTreeIterator file = walk.getTree(FILE_TREE_INDEX, FileTreeIterator.class); if (file != null) { fileId = file.getEntryObjectId(); fileStreamSupplier = file::openEntryStream; loadedFromTreeWalk = true; } else { throw new IOException(MessageFormat.format( JGitText.get().cannotReadFile, pathWithOriginalContent)); } } if (fileStreamSupplier == null) fileStreamSupplier = inCore() ? InputStream::nullInputStream : () -> new FileInputStream(f); FileMode fileMode = fh.getNewMode() != null ? fh.getNewMode() : FileMode.REGULAR_FILE; ContentStreamLoader resultStreamLoader; if (PatchType.GIT_BINARY.equals(fh.getPatchType())) { // binary patches are processed in a streaming fashion. Some // binary patches do random access on the input data, so we can't // overwrite the file while we're streaming. resultStreamLoader = applyBinary(pathWithOriginalContent, f, fh, fileStreamSupplier, fileId, result); } else { String filterCommand = walk != null ? walk.getFilterCommand( Constants.ATTR_FILTER_TYPE_CLEAN) : null; RawText raw = getRawText(f, fileStreamSupplier, fileId, pathWithOriginalContent, loadedFromTreeWalk, filterCommand, convertCrLf); resultStreamLoader = applyText(raw, fh, result); } if (resultStreamLoader == null || !result.getErrors().isEmpty()) { return; } if (f != null) { // Write to a buffer and copy to the file only if everything was // fine. TemporaryBuffer buffer = new TemporaryBuffer.LocalFile(null); try { CheckoutMetadata metadata = new CheckoutMetadata(streamType, smudgeFilterCommand); try (TemporaryBuffer buf = buffer) { DirCacheCheckout.getContent(repo, pathWithOriginalContent, metadata, resultStreamLoader.supplier, workingTreeOptions, buf); } try (InputStream bufIn = buffer.openInputStream()) { Files.copy(bufIn, f.toPath(), StandardCopyOption.REPLACE_EXISTING); } } finally { buffer.destroy(); } repo.getFS().setExecute(f, fileMode == FileMode.EXECUTABLE_FILE); } Instant lastModified = f == null ? null : repo.getFS().lastModifiedInstant(f); Attributes attributes = walk != null ? walk.getAttributes() : new Attributes(); DirCacheEntry dce = insertToIndex( resultStreamLoader.supplier.load(), fh.getNewPath().getBytes(StandardCharsets.UTF_8), fileMode, lastModified, resultStreamLoader.length, attributes.get(Constants.ATTR_FILTER)); dirCacheBuilder.add(dce); if (PatchType.GIT_BINARY.equals(fh.getPatchType()) && fh.getNewId() != null && fh.getNewId().isComplete() && !fh.getNewId().toObjectId().equals(dce.getObjectId())) { result.addError(MessageFormat.format( JGitText.get().applyBinaryResultOidWrong, pathWithOriginalContent), fh.getOldPath(), null); } } private DirCacheEntry insertToIndex(InputStream input, byte[] path, FileMode fileMode, Instant lastModified, long length, Attribute lfsAttribute) throws IOException { DirCacheEntry dce = new DirCacheEntry(path, DirCacheEntry.STAGE_0); dce.setFileMode(fileMode); if (lastModified != null) { dce.setLastModified(lastModified); } dce.setLength(length); try (LfsInputStream is = LfsFactory.getInstance() .applyCleanFilter(repo, input, length, lfsAttribute)) { dce.setObjectId(inserter.insert(OBJ_BLOB, is.getLength(), is)); } return dce; } /** * Gets the raw text of the given file. * * @param file * to read from * @param fileStreamSupplier * if fromTreewalk, the stream of the file content * @param fileId * of the file * @param path * of the file * @param fromTreeWalk * whether the file was loaded by a {@link TreeWalk} * @param filterCommand * for reading the file content * @param convertCrLf * whether a CR-LF conversion is needed * @return the result raw text * @throws IOException * in case of filtering issues */ private RawText getRawText(@Nullable File file, StreamSupplier fileStreamSupplier, ObjectId fileId, String path, boolean fromTreeWalk, String filterCommand, boolean convertCrLf) throws IOException { if (fromTreeWalk) { // Can't use file.openEntryStream() as we cannot control its CR-LF // conversion. try (InputStream input = filterClean(repo, path, fileStreamSupplier.load(), convertCrLf, filterCommand)) { return new RawText(IO.readWholeStream(input, 0).array()); } } if (convertCrLf) { try (InputStream input = EolStreamTypeUtil.wrapInputStream( fileStreamSupplier.load(), EolStreamType.TEXT_LF)) { return new RawText(IO.readWholeStream(input, 0).array()); } } if (inCore() && fileId.equals(ObjectId.zeroId())) { return new RawText(new byte[] {}); } return new RawText(file); } private InputStream filterClean(Repository repository, String path, InputStream fromFile, boolean convertCrLf, String filterCommand) throws IOException { InputStream input = fromFile; if (convertCrLf) { input = EolStreamTypeUtil.wrapInputStream(input, EolStreamType.TEXT_LF); } if (StringUtils.isEmptyOrNull(filterCommand)) { return input; } if (FilterCommandRegistry.isRegistered(filterCommand)) { LocalFile buffer = new TemporaryBuffer.LocalFile(null, inCoreSizeLimit); FilterCommand command = FilterCommandRegistry.createFilterCommand( filterCommand, repository, input, buffer); while (command.run() != -1) { // loop as long as command.run() tells there is work to do } return buffer.openInputStreamWithAutoDestroy(); } FS fs = repository.getFS(); ProcessBuilder filterProcessBuilder = fs.runInShell(filterCommand, new String[0]); filterProcessBuilder.directory(repository.getWorkTree()); filterProcessBuilder.environment().put(Constants.GIT_DIR_KEY, repository.getDirectory().getAbsolutePath()); ExecutionResult result; try { result = fs.execute(filterProcessBuilder, input); } catch (IOException | InterruptedException e) { throw new IOException( new FilterFailedException(e, filterCommand, path)); } int rc = result.getRc(); if (rc != 0) { throw new IOException(new FilterFailedException(rc, filterCommand, path, result.getStdout().toByteArray(4096), RawParseUtils .decode(result.getStderr().toByteArray(4096)))); } return result.getStdout().openInputStreamWithAutoDestroy(); } private boolean needsCrLfConversion(File f, FileHeader fileHeader) throws IOException { if (PatchType.GIT_BINARY.equals(fileHeader.getPatchType())) { return false; } if (!hasCrLf(fileHeader)) { try (InputStream input = new FileInputStream(f)) { return RawText.isCrLfText(input); } } return false; } private static boolean hasCrLf(FileHeader fileHeader) { if (PatchType.GIT_BINARY.equals(fileHeader.getPatchType())) { return false; } for (HunkHeader header : fileHeader.getHunks()) { byte[] buf = header.getBuffer(); int hunkEnd = header.getEndOffset(); int lineStart = header.getStartOffset(); while (lineStart < hunkEnd) { int nextLineStart = RawParseUtils.nextLF(buf, lineStart); if (nextLineStart > hunkEnd) { nextLineStart = hunkEnd; } if (nextLineStart <= lineStart) { break; } if (nextLineStart - lineStart > 1) { char first = (char) (buf[lineStart] & 0xFF); if (first == ' ' || first == '-') { // It's an old line. Does it end in CR-LF? if (buf[nextLineStart - 2] == '\r') { return true; } } } lineStart = nextLineStart; } } return false; } private ObjectId hash(File f) throws IOException { try (FileInputStream fis = new FileInputStream(f); SHA1InputStream shaStream = new SHA1InputStream(fis, f.length())) { shaStream.transferTo(OutputStream.nullOutputStream()); return shaStream.getHash().toObjectId(); } } private boolean checkOid(ObjectId baseId, ObjectId id, ChangeType type, File f, String path, Result result) throws IOException { boolean hashOk = false; if (id != null) { hashOk = baseId.equals(id); if (!hashOk && ADD.equals(type) && ObjectId.zeroId().equals(baseId)) { // We create a new file. The OID of an empty file is not the // zero id! hashOk = Constants.EMPTY_BLOB_ID.equals(id); } } else if (!inCore()) { if (ObjectId.zeroId().equals(baseId)) { // File empty is OK. hashOk = !f.exists() || f.length() == 0; } else { hashOk = baseId.equals(hash(f)); } } if (!hashOk) { result.addError(MessageFormat .format(JGitText.get().applyBinaryBaseOidWrong, path), path, null); } return hashOk; } private boolean inCore() { return beforeTree != null; } /** * Provide stream, along with the length of the object. We use this once to * patch to the working tree, once to write the index. For on-disk * operation, presumably we could stream to the destination file, and then * read back the stream from disk. We don't because it is more complex. */ private static class ContentStreamLoader { StreamSupplier supplier; long length; ContentStreamLoader(StreamSupplier supplier, long length) { this.supplier = supplier; this.length = length; } } /** * Applies a binary patch. * * @param path * pathname of the file to write. * @param f * destination file * @param fh * the patch to apply * @param inputSupplier * a supplier for the contents of the old file * @param id * SHA1 for the old content * @param result * The patch application result * @return a loader for the new content, or null if invalid. * @throws IOException * if an IO error occurred * @throws UnsupportedOperationException * if an operation isn't supported */ private @Nullable ContentStreamLoader applyBinary(String path, File f, FileHeader fh, StreamSupplier inputSupplier, ObjectId id, Result result) throws UnsupportedOperationException, IOException { if (!fh.getOldId().isComplete() || !fh.getNewId().isComplete()) { result.addError(MessageFormat .format(JGitText.get().applyBinaryOidTooShort, path), path, null); return null; } BinaryHunk hunk = fh.getForwardBinaryHunk(); // A BinaryHunk has the start at the "literal" or "delta" token. Data // starts on the next line. int start = RawParseUtils.nextLF(hunk.getBuffer(), hunk.getStartOffset()); int length = hunk.getEndOffset() - start; switch (hunk.getType()) { case LITERAL_DEFLATED: { // This just overwrites the file. We need to check the hash of // the base. if (!checkOid(fh.getOldId().toObjectId(), id, fh.getChangeType(), f, path, result)) { return null; } StreamSupplier supp = () -> new InflaterInputStream( new BinaryHunkInputStream(new ByteArrayInputStream( hunk.getBuffer(), start, length))); return new ContentStreamLoader(supp, hunk.getSize()); } case DELTA_DEFLATED: { // Unfortunately delta application needs random access to the // base to construct the result. byte[] base; try (InputStream in = inputSupplier.load()) { base = IO.readWholeStream(in, 0).array(); } // At least stream the result! We don't have to close these streams, // as they don't hold resources. StreamSupplier supp = () -> new BinaryDeltaInputStream(base, new InflaterInputStream( new BinaryHunkInputStream(new ByteArrayInputStream( hunk.getBuffer(), start, length)))); // This just reads the first bits of the stream. long finalSize = ((BinaryDeltaInputStream) supp.load()).getExpectedResultSize(); return new ContentStreamLoader(supp, finalSize); } default: throw new UnsupportedOperationException(MessageFormat.format( JGitText.get().applyBinaryPatchTypeNotSupported, hunk.getType().name())); } } @SuppressWarnings("ByteBufferBackingArray") private @Nullable ContentStreamLoader applyText(RawText rt, FileHeader fh, Result result) throws IOException { List oldLines = new ArrayList<>(rt.size()); for (int i = 0; i < rt.size(); i++) { oldLines.add(rt.getRawString(i)); } List newLines = new ArrayList<>(oldLines); int afterLastHunk = 0; int lineNumberShift = 0; int lastHunkNewLine = -1; boolean lastWasRemoval = false; boolean noNewLineAtEndOfNew = false; for (HunkHeader hh : fh.getHunks()) { // We assume hunks to be ordered if (hh.getNewStartLine() <= lastHunkNewLine) { result.addError(JGitText.get().applyTextPatchUnorderedHunks, fh.getOldPath(), hh); return null; } lastHunkNewLine = hh.getNewStartLine(); byte[] b = new byte[hh.getEndOffset() - hh.getStartOffset()]; System.arraycopy(hh.getBuffer(), hh.getStartOffset(), b, 0, b.length); RawText hrt = new RawText(b); List hunkLines = new ArrayList<>(hrt.size()); for (int i = 0; i < hrt.size(); i++) { hunkLines.add(hrt.getRawString(i)); } if (hh.getNewStartLine() == 0) { // Must be the single hunk for clearing all content if (fh.getHunks().size() == 1 && canApplyAt(hunkLines, newLines, 0)) { newLines.clear(); break; } result.addError(JGitText.get().applyTextPatchSingleClearingHunk, fh.getOldPath(), hh); return null; } // Hunk lines as reported by the hunk may be off, so don't rely on // them. int applyAt = hh.getNewStartLine() - 1 + lineNumberShift; // But they definitely should not go backwards. if (applyAt < afterLastHunk && lineNumberShift < 0) { applyAt = hh.getNewStartLine() - 1; lineNumberShift = 0; } if (applyAt < afterLastHunk) { result.addError(JGitText.get().applyTextPatchUnorderedHunkApplications, fh.getOldPath(), hh); return null; } boolean applies = false; int oldLinesInHunk = hh.getLinesContext() + hh.getOldImage().getLinesDeleted(); if (oldLinesInHunk <= 1) { // Don't shift hunks without context lines. Just try the // position corrected by the current lineNumberShift, and if // that fails, the position recorded in the hunk header. applies = canApplyAt(hunkLines, newLines, applyAt); if (!applies && lineNumberShift != 0) { applyAt = hh.getNewStartLine() - 1; applies = applyAt >= afterLastHunk && canApplyAt(hunkLines, newLines, applyAt); } } else { int maxShift = applyAt - afterLastHunk; for (int shift = 0; shift <= maxShift; shift++) { if (canApplyAt(hunkLines, newLines, applyAt - shift)) { applies = true; applyAt -= shift; break; } } if (!applies) { // Try shifting the hunk downwards applyAt = hh.getNewStartLine() - 1 + lineNumberShift; maxShift = newLines.size() - applyAt - oldLinesInHunk; for (int shift = 1; shift <= maxShift; shift++) { if (canApplyAt(hunkLines, newLines, applyAt + shift)) { applies = true; applyAt += shift; break; } } } } if (!applies) { result.addError(JGitText.get().applyTextPatchCannotApplyHunk, fh.getOldPath(), hh); return null; } // Hunk applies at applyAt. Apply it, and update afterLastHunk and // lineNumberShift lineNumberShift = applyAt - hh.getNewStartLine() + 1; int sz = hunkLines.size(); for (int j = 1; j < sz; j++) { ByteBuffer hunkLine = hunkLines.get(j); if (!hunkLine.hasRemaining()) { // Completely empty line; accept as empty context line applyAt++; lastWasRemoval = false; continue; } switch (hunkLine.array()[hunkLine.position()]) { case ' ': applyAt++; lastWasRemoval = false; break; case '-': newLines.remove(applyAt); lastWasRemoval = true; break; case '+': newLines.add(applyAt++, slice(hunkLine, 1)); lastWasRemoval = false; break; case '\\': if (!lastWasRemoval && isNoNewlineAtEnd(hunkLine)) { noNewLineAtEndOfNew = true; } break; default: break; } } afterLastHunk = applyAt; } // If the last line should have a newline, add a null sentinel if (lastHunkNewLine >= 0 && afterLastHunk == newLines.size()) { // Last line came from the patch if (!noNewLineAtEndOfNew) { newLines.add(null); } } else if (!rt.isMissingNewlineAtEnd()) { newLines.add(null); } // We could check if old == new, but the short-circuiting complicates // logic for inCore patching, so just write the new thing regardless. TemporaryBuffer buffer = new TemporaryBuffer.LocalFile(null); // TemporaryBuffer::length reports incorrect length until the buffer // is closed. To use it as input for ContentStreamLoader below, we // need a wrapper with a reliable in-progress length. try (CountingOutputStream out = new CountingOutputStream(buffer)) { for (Iterator l = newLines.iterator(); l.hasNext();) { ByteBuffer line = l.next(); if (line == null) { // Must be the marker for the final newline break; } out.write(line.array(), line.position(), line.remaining()); if (l.hasNext()) { out.write('\n'); } } return new ContentStreamLoader(buffer::openInputStream, out.getCount()); } } @SuppressWarnings("ByteBufferBackingArray") private boolean canApplyAt(List hunkLines, List newLines, int line) { int sz = hunkLines.size(); int limit = newLines.size(); int pos = line; for (int j = 1; j < sz; j++) { ByteBuffer hunkLine = hunkLines.get(j); if (!hunkLine.hasRemaining()) { // Empty line. Accept as empty context line. if (pos >= limit || newLines.get(pos).hasRemaining()) { return false; } pos++; continue; } switch (hunkLine.array()[hunkLine.position()]) { case ' ': case '-': if (pos >= limit || !newLines.get(pos).equals(slice(hunkLine, 1))) { return false; } pos++; break; default: break; } } return true; } @SuppressWarnings("ByteBufferBackingArray") private ByteBuffer slice(ByteBuffer b, int off) { int newOffset = b.position() + off; return ByteBuffer.wrap(b.array(), newOffset, b.limit() - newOffset); } @SuppressWarnings("ByteBufferBackingArray") private boolean isNoNewlineAtEnd(ByteBuffer hunkLine) { return Arrays.equals(NO_EOL, 0, NO_EOL.length, hunkLine.array(), hunkLine.position(), hunkLine.limit()); } /** * An {@link InputStream} that updates a {@link SHA1} on every byte read. */ private static class SHA1InputStream extends InputStream { private final SHA1 hash; private final InputStream in; SHA1InputStream(InputStream in, long size) { hash = SHA1.newInstance(); hash.update(Constants.encodedTypeString(Constants.OBJ_BLOB)); hash.update((byte) ' '); hash.update(Constants.encodeASCII(size)); hash.update((byte) 0); this.in = in; } public SHA1 getHash() { return hash; } @Override public int read() throws IOException { int b = in.read(); if (b >= 0) { hash.update((byte) b); } return b; } @Override public int read(byte[] b, int off, int len) throws IOException { int n = in.read(b, off, len); if (n > 0) { hash.update(b, off, n); } return n; } @Override public void close() throws IOException { in.close(); } } }