blob: a327095c814dd611927820096ac010c0364b8b9e [file] [log] [blame]
/*
* 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.
* <p>
* After instantiating, applyPatch() should be called once.
* </p>
*
* @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<String> paths;
private List<Error> errors = new ArrayList<>();
/**
* Get modified paths
*
* @return List of modified paths.
*/
public List<String> 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<Error> 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<String> 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<ByteBuffer> oldLines = new ArrayList<>(rt.size());
for (int i = 0; i < rt.size(); i++) {
oldLines.add(rt.getRawString(i));
}
List<ByteBuffer> 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<ByteBuffer> 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<ByteBuffer> 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<ByteBuffer> hunkLines,
List<ByteBuffer> 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();
}
}
}