From 170244d05977491271a1cc234583d2e5ba75145d Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Fri, 11 Aug 2023 21:40:13 +0200 Subject: [PATCH] Checkout: better directory handling This backports the upstream security fix to downstream stable-5.13 branch. (cherry picked from commit 9072103f3b3cf64dd12ad2949836ab98f62dabf1) When checking out a file into the working tree ensure that all parent directories of the file below the working tree root are actually directories and do exist before we try to create the file. When multiple files are to be checked out (or even a whole tree), this may check the same directories over and over again. Asking the file system every time for file attributes is a potentially expensive operation. As a remedy, introduce an in-memory cache of directory states for a particular check-out operation. Apply the same fix also in the ResolveMerger, which may also check out files, and also in the PatchApplier. In PatchApplier, also validate paths. Change-Id: Ie12864c54c9f901a2ccee7caddec73027f353111 Signed-off-by: Thomas Wolf Signed-off-by: Matthias Sohn --- .../eclipse/jgit/junit/TestRepository.java | 19 ++ .../org/eclipse/jgit/diff/dotgit.patch | 9 + .../org/eclipse/jgit/diff/dotgit2.patch | 9 + .../org/eclipse/jgit/symlinks/.gitattributes | 1 + .../org/eclipse/jgit/symlinks/dirtest.patch | 9 + .../eclipse/jgit/api/ApplyCommandTest.java | 14 + .../jgit/dircache/DirCacheEntryTest.java | 12 +- .../dircache/InvalidPathCheckoutTest.java | 61 ++++ .../eclipse/jgit/symlinks/DirectoryTest.java | 254 ++++++++++++++ .../eclipse/jgit/internal/JGitText.properties | 4 + .../org/eclipse/jgit/api/ApplyCommand.java | 63 +++- .../org/eclipse/jgit/api/CheckoutCommand.java | 30 +- .../eclipse/jgit/api/StashApplyCommand.java | 20 +- .../org/eclipse/jgit/dircache/Checkout.java | 238 ++++++++++++++ .../jgit/dircache/DirCacheCheckout.java | 105 +----- .../org/eclipse/jgit/internal/JGitText.java | 4 + .../org/eclipse/jgit/lib/FileModeCache.java | 309 ++++++++++++++++++ .../org/eclipse/jgit/merge/ResolveMerger.java | 42 ++- 18 files changed, 1061 insertions(+), 142 deletions(-) create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes create mode 100644 org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java create mode 100644 org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java diff --git a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java index 54e4a09ee5..d91ef64688 100644 --- a/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java +++ b/org.eclipse.jgit.junit/src/org/eclipse/jgit/junit/TestRepository.java @@ -275,6 +275,25 @@ public class TestRepository implements AutoCloseable { return e; } + /** + * Construct a symlink mode tree entry. + * + * @param path + * path of the symlink. + * @param blob + * a blob, previously constructed in the repository. + * @return the entry. + * @throws Exception + * if an error occurred + * @since 5.13.3 + */ + public DirCacheEntry link(String path, RevBlob blob) throws Exception { + DirCacheEntry e = new DirCacheEntry(path); + e.setFileMode(FileMode.SYMLINK); + e.setObjectId(blob); + return e; + } + /** * Construct a tree from a specific listing of file entries. * diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch new file mode 100644 index 0000000000..802fa15465 --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit.patch @@ -0,0 +1,9 @@ +diff --git a/.git/b b/.git/b +new file mode 100644 +index 0000000..de98044 +--- /dev/null ++++ b/.git/b +@@ -0,0 +1,3 @@ ++a ++b ++c \ No newline at end of file diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch new file mode 100644 index 0000000000..03cacbaeed --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/diff/dotgit2.patch @@ -0,0 +1,9 @@ +diff --git a/.GIT/b b/.GIT/b +new file mode 100644 +index 0000000..de98044 +--- /dev/null ++++ b/.git/b +@@ -0,0 +1,3 @@ ++a ++b ++c \ No newline at end of file diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes new file mode 100644 index 0000000000..b38f87f9e3 --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/.gitattributes @@ -0,0 +1 @@ +*.patch -crlf diff --git a/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch new file mode 100644 index 0000000000..a275c8593f --- /dev/null +++ b/org.eclipse.jgit.test/tst-rsrc/org/eclipse/jgit/symlinks/dirtest.patch @@ -0,0 +1,9 @@ +diff --git a/a/b b/a/b +new file mode 100644 +index 0000000..de98044 +--- /dev/null ++++ b/a/b +@@ -0,0 +1,3 @@ ++a ++b ++c \ No newline at end of file diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/ApplyCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/ApplyCommandTest.java index 867310b60c..71f4ecbcab 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/ApplyCommandTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/ApplyCommandTest.java @@ -250,6 +250,20 @@ public class ApplyCommandTest extends RepositoryTestCase { } } + private void dotGitTest(String fileName) throws Exception { + init(fileName, false, false); + } + + @Test(expected = PatchApplyException.class) + public void testDotGit() throws Exception { + dotGitTest("dotgit"); + } + + @Test(expected = PatchApplyException.class) + public void testDotGit2() throws Exception { + dotGitTest("dotgit2"); + } + private void checkBinary(String name, boolean hasPreImage) throws Exception { checkBinary(name, hasPreImage, 1); diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java index 8e84dfa318..01d1e0282d 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/DirCacheEntryTest.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2009, 2020 Google Inc. and others + * Copyright (C) 2009, 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 @@ -46,6 +46,16 @@ public class DirCacheEntryTest { assertFalse(isValidPath("a/")); assertFalse(isValidPath("ab/cd/ef/")); assertFalse(isValidPath("a\u0000b")); + assertFalse(isValidPath(".git")); + assertFalse(isValidPath(".GIT")); + assertFalse(isValidPath(".Git")); + assertFalse(isValidPath(".git/b")); + assertFalse(isValidPath(".GIT/b")); + assertFalse(isValidPath(".Git/b")); + assertFalse(isValidPath("x/y/.git/z/b")); + assertFalse(isValidPath("x/y/.GIT/z/b")); + assertFalse(isValidPath("x/y/.Git/z/b")); + assertTrue(isValidPath("git/b")); } @SuppressWarnings("unused") diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java new file mode 100644 index 0000000000..e3bc85a512 --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/dircache/InvalidPathCheckoutTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2023 Thomas Wolf 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.dircache; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.io.File; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand.ResetType; +import org.eclipse.jgit.junit.RepositoryTestCase; +import org.eclipse.jgit.junit.TestRepository; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevBlob; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.Test; + +/** + * Tests for checking out with invalid paths. + */ +public class InvalidPathCheckoutTest extends RepositoryTestCase { + + private DirCacheEntry brokenEntry(String fileName, RevBlob blob) { + DirCacheEntry entry = new DirCacheEntry("XXXX/" + fileName); + entry.path[0] = '.'; + entry.path[1] = 'g'; + entry.path[2] = 'i'; + entry.path[3] = 't'; + entry.setFileMode(FileMode.REGULAR_FILE); + entry.setObjectId(blob); + return entry; + } + + @Test + public void testCheckoutIntoDotGit() throws Exception { + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + // DirCacheEntry does not allow any path component to contain + // ".git". C git also forbids this. But what if somebody creates + // such an entry explicitly? + RevCommit base = repo + .commit(repo.tree(brokenEntry("b", repo.blob("test")))); + try (Git git = new Git(db)) { + assertThrows(InvalidPathException.class, () -> git.reset() + .setMode(ResetType.HARD).setRef(base.name()).call()); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + } + } + } + +} diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java new file mode 100644 index 0000000000..05d4cea54b --- /dev/null +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/symlinks/DirectoryTest.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2023 Thomas Wolf 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.symlinks; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + +import org.eclipse.jgit.api.ApplyResult; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ResetCommand.ResetType; +import org.eclipse.jgit.api.errors.PatchApplyException; +import org.eclipse.jgit.junit.RepositoryTestCase; +import org.eclipse.jgit.junit.TestRepository; +import org.eclipse.jgit.lib.ConfigConstants; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.FileUtils; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public class DirectoryTest extends RepositoryTestCase { + + @BeforeClass + public static void checkPrecondition() throws Exception { + Assume.assumeTrue(FS.DETECTED.supportsSymlinks()); + Path tempDir = Files.createTempDirectory("jgit"); + try { + Path a = tempDir.resolve("a"); + Files.write(a, "test".getBytes(StandardCharsets.UTF_8)); + Path b = tempDir.resolve("A"); + Assume.assumeTrue(Files.exists(b)); + } finally { + FileUtils.delete(tempDir.toFile(), + FileUtils.RECURSIVE | FileUtils.IGNORE_ERRORS); + } + } + + @Parameters(name = "core.symlinks={0}") + public static Boolean[] parameters() { + return new Boolean[] { Boolean.TRUE, Boolean.FALSE }; + } + + @Parameter(0) + public boolean useSymlinks; + + private void checkFiles() throws Exception { + File a = new File(trash, "a"); + assertTrue("a should be a directory", + Files.isDirectory(a.toPath(), LinkOption.NOFOLLOW_LINKS)); + File b = new File(a, "b"); + assertTrue("a/b should exist", b.isFile()); + File x = new File(trash, "x"); + assertTrue("x should be a directory", + Files.isDirectory(x.toPath(), LinkOption.NOFOLLOW_LINKS)); + File y = new File(x, "y"); + assertTrue("x/y should exist", y.isFile()); + } + + @Test + public void testCheckout() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + // Create links directly in the git repo, then use a hard reset + // to get them into the workspace. + RevCommit base = repo.commit( + repo.tree( + repo.link("A", repo.blob(".git")), + repo.file("a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + checkFiles(); + } + } + } + + @Test + public void testCheckout2() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree( + repo.link("A/B", repo.blob("../.git")), + repo.file("a/b/a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + boolean testFiles = true; + try { + git.reset().setMode(ResetType.HARD).setRef(base.name()) + .call(); + } catch (Exception e) { + if (!useSymlinks) { + // There is a file in the middle of the path where we'd + // expect a directory. This case is not handled + // anywhere. What would be a better reply than an IOE? + testFiles = false; + } else { + throw e; + } + } + File a = new File(new File(trash, ".git"), "a"); + assertFalse(".git/a should not exist", a.exists()); + if (testFiles) { + a = new File(trash, "a"); + assertTrue("a should be a directory", Files.isDirectory( + a.toPath(), LinkOption.NOFOLLOW_LINKS)); + File b = new File(a, "b"); + assertTrue("a/b should be a directory", Files.isDirectory( + a.toPath(), LinkOption.NOFOLLOW_LINKS)); + a = new File(b, "a"); + assertTrue("a/b/a should be a directory", Files.isDirectory( + a.toPath(), LinkOption.NOFOLLOW_LINKS)); + b = new File(a, "b"); + assertTrue("a/b/a/b should exist", b.isFile()); + File x = new File(trash, "x"); + assertTrue("x should be a directory", Files.isDirectory( + x.toPath(), LinkOption.NOFOLLOW_LINKS)); + File y = new File(x, "y"); + assertTrue("x/y should exist", y.isFile()); + } + } + } + } + + @Test + public void testMerge() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree(repo.file("q", repo.blob("test")))); + RevCommit side = repo.commit( + repo.tree( + repo.link("A", repo.blob(".git")), + repo.file("a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + git.merge().include(side) + .setMessage("merged").call(); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + checkFiles(); + } + } + } + + @Test + public void testMerge2() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree( + repo.file("q", repo.blob("test")), + repo.link("A", repo.blob(".git")))); + RevCommit side = repo.commit( + repo.tree( + repo.file("a/b", repo.blob("test")), + repo.file("x/y", repo.blob("test2")))); + try (Git git = new Git(db)) { + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + git.merge().include(side) + .setMessage("merged").call(); + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + checkFiles(); + } + } + } + + @Test + public void testApply() throws Exception { + StoredConfig config = db.getConfig(); + config.setBoolean(ConfigConstants.CONFIG_CORE_SECTION, null, + ConfigConstants.CONFIG_KEY_SYMLINKS, useSymlinks); + config.save(); + // PatchApplier doesn't do symlinks yet. + try (TestRepository repo = new TestRepository<>(db)) { + db.incrementOpen(); + RevCommit base = repo.commit( + repo.tree( + repo.file("x", repo.blob("test")), + repo.link("A", repo.blob(".git")))); + try (Git git = new Git(db)) { + boolean testFiles = true; + git.reset().setMode(ResetType.HARD).setRef(base.name()).call(); + try (InputStream patchStream = this.getClass() + .getResourceAsStream("dirtest.patch")) { + ApplyResult result = git.apply().setPatch(patchStream).call(); + assertNotNull(result); + } catch (PatchApplyException e) { + if (!useSymlinks) { + // There is a file there, so the patch won't apply. + // Unclear whether an IOE is the correct response, + // though. Probably some negative PatchApplier.Result is + // more appropriate. + testFiles = false; + } else { + throw e; + } + } + File b = new File(new File(trash, ".git"), "b"); + assertFalse(".git/b should not exist", b.exists()); + if (testFiles) { + File a = new File(trash, "a"); + assertTrue("a should be a directory", + Files.isDirectory(a.toPath(), LinkOption.NOFOLLOW_LINKS)); + b = new File(a, "b"); + assertTrue("a/b should exist", b.isFile()); + } + } + } + } +} \ No newline at end of file diff --git a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties index f9b0595080..62a2f0d8c7 100644 --- a/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties +++ b/org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties @@ -16,6 +16,8 @@ anSSHSessionHasBeenAlreadyCreated=An SSH session has been already created applyBinaryBaseOidWrong=Cannot apply binary patch; OID for file {0} does not match applyBinaryOidTooShort=Binary patch for file {0} does not have full IDs applyBinaryResultOidWrong=Result of binary patch for file {0} has wrong OID. +applyPatchDestInvalid=Destination path in patch is invalid +applyPatchSourceInvalid==Source path in patch is invalid applyingCommit=Applying {0} archiveFormatAlreadyAbsent=Archive format already absent: {0} archiveFormatAlreadyRegistered=Archive format already registered with different implementation: {0} @@ -550,6 +552,8 @@ packWasDeleted=Pack file {0} was deleted, removing it from pack list packWriterStatistics=Total {0,number,#0} (delta {1,number,#0}), reused {2,number,#0} (delta {3,number,#0}) panicCantRenameIndexFile=Panic: index file {0} must be renamed to replace {1}; until then repository is corrupt patchApplyException=Cannot apply: {0} +patchApplyErrorWithHunk=Error applying patch in {0}, hunk {1}: {2} +patchApplyErrorWithoutHunk=Error applying patch in {0}: {1} patchFormatException=Format error: {0} pathNotConfigured=Submodule path is not configured peeledLineBeforeRef=Peeled line before ref. diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/ApplyCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/ApplyCommand.java index 583767af3f..58b339a237 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/ApplyCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/ApplyCommand.java @@ -22,6 +22,7 @@ import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.text.MessageFormat; import java.util.ArrayList; +import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.zip.InflaterInputStream; @@ -38,12 +39,14 @@ import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; import org.eclipse.jgit.dircache.DirCacheIterator; +import org.eclipse.jgit.errors.CorruptObjectException; import org.eclipse.jgit.errors.LargeObjectException; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.internal.JGitText; 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.ObjectLoader; import org.eclipse.jgit.lib.ObjectStream; @@ -65,6 +68,7 @@ import org.eclipse.jgit.util.FileUtils; import org.eclipse.jgit.util.IO; 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; @@ -72,6 +76,12 @@ import org.eclipse.jgit.util.io.BinaryHunkInputStream; import org.eclipse.jgit.util.io.EolStreamTypeUtil; import org.eclipse.jgit.util.sha1.SHA1; +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; + /** * Apply a patch to files and/or to the index. * @@ -131,30 +141,34 @@ public class ApplyCommand extends GitCommand { throw new PatchFormatException(p.getErrors()); } Repository repository = getRepository(); + FileModeCache directoryCache = new FileModeCache(repo); DirCache cache = repository.readDirCache(); for (FileHeader fh : p.getFiles()) { ChangeType type = fh.getChangeType(); File f = null; + verifyExistence(fh, + new File(repo.getWorkTree(), fh.getOldPath()), + new File(repo.getWorkTree(), fh.getNewPath())); switch (type) { case ADD: - f = getFile(fh.getNewPath(), true); + f = getFile(fh.getNewPath(), true, directoryCache); apply(repository, fh.getNewPath(), cache, f, fh); break; case MODIFY: - f = getFile(fh.getOldPath(), false); + f = getFile(fh.getOldPath(), false, directoryCache); apply(repository, fh.getOldPath(), cache, f, fh); break; case DELETE: - f = getFile(fh.getOldPath(), false); + f = getFile(fh.getOldPath(), false, directoryCache); if (!f.delete()) throw new PatchApplyException(MessageFormat.format( JGitText.get().cannotDeleteFile, f)); break; case RENAME: - f = getFile(fh.getOldPath(), false); - File dest = getFile(fh.getNewPath(), false); + f = getFile(fh.getOldPath(), false, directoryCache); + File dest = getFile(fh.getNewPath(), false, directoryCache); try { - FileUtils.mkdirs(dest.getParentFile(), true); + directoryCache.safeCreateParentDirectory(fh.getNewPath(), dest.getParentFile(), false); FileUtils.rename(f, dest, StandardCopyOption.ATOMIC_MOVE); } catch (IOException e) { @@ -164,9 +178,9 @@ public class ApplyCommand extends GitCommand { apply(repository, fh.getOldPath(), cache, dest, fh); break; case COPY: - f = getFile(fh.getOldPath(), false); - File target = getFile(fh.getNewPath(), false); - FileUtils.mkdirs(target.getParentFile(), true); + f = getFile(fh.getOldPath(), false, directoryCache); + File target = getFile(fh.getNewPath(), false, directoryCache); + directoryCache.safeCreateParentDirectory(fh.getNewPath(), target.getParentFile(), false); Files.copy(f.toPath(), target.toPath()); apply(repository, fh.getOldPath(), cache, target, fh); } @@ -179,13 +193,40 @@ public class ApplyCommand extends GitCommand { return r; } - private File getFile(String path, boolean create) + private void verifyExistence(FileHeader fh, File src, File dest) + throws IOException, PatchApplyException { + boolean srcShouldExist = Arrays + .asList(new ChangeType[] { MODIFY, DELETE, RENAME, COPY }) + .contains(fh.getChangeType()); + boolean destShouldNotExist = Arrays + .asList(new ChangeType[] { ADD, RENAME, COPY }) + .contains(fh.getChangeType()); + if (srcShouldExist && !validGitPath(fh.getOldPath())) { + throw new PatchApplyException(MessageFormat.format( + JGitText.get().applyPatchSourceInvalid, fh.getOldPath())); + } + if (destShouldNotExist && !validGitPath(fh.getNewPath())) { + throw new PatchApplyException(MessageFormat.format( + JGitText.get().applyPatchDestInvalid, fh.getNewPath())); + } + } + + private boolean validGitPath(String path) { + try { + SystemReader.getInstance().checkPath(path); + return true; + } catch (CorruptObjectException e) { + return false; + } + } + + private File getFile(String path, boolean create, FileModeCache directoryCache) throws PatchApplyException { File f = new File(getRepository().getWorkTree(), path); if (create) { try { File parent = f.getParentFile(); - FileUtils.mkdirs(parent, true); + directoryCache.safeCreateParentDirectory(path, parent, false); FileUtils.createNewFile(f); } catch (IOException e) { throw new PatchApplyException(MessageFormat.format( diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java index 847ab0a9a8..8edae5a580 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/CheckoutCommand.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2010, Chris Aniszczyk - * Copyright (C) 2011, 2020 Matthias Sohn and others + * Copyright (C) 2011, 2023 Matthias Sohn 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 @@ -28,6 +28,7 @@ import org.eclipse.jgit.api.errors.InvalidRefNameException; import org.eclipse.jgit.api.errors.JGitInternalException; import org.eclipse.jgit.api.errors.RefAlreadyExistsException; import org.eclipse.jgit.api.errors.RefNotFoundException; +import org.eclipse.jgit.dircache.Checkout; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; @@ -411,6 +412,7 @@ public class CheckoutCommand extends GitCommand { protected CheckoutCommand checkoutPaths() throws IOException, RefNotFoundException { actuallyModifiedPaths = new HashSet<>(); + Checkout checkout = new Checkout(repo).setRecursiveDeletion(true); DirCache dc = repo.lockDirCache(); try (RevWalk revWalk = new RevWalk(repo); TreeWalk treeWalk = new TreeWalk(repo, @@ -419,10 +421,10 @@ public class CheckoutCommand extends GitCommand { if (!checkoutAllPaths) treeWalk.setFilter(PathFilterGroup.createFromStrings(paths)); if (isCheckoutIndex()) - checkoutPathsFromIndex(treeWalk, dc); + checkoutPathsFromIndex(treeWalk, dc, checkout); else { RevCommit commit = revWalk.parseCommit(getStartPointObjectId()); - checkoutPathsFromCommit(treeWalk, dc, commit); + checkoutPathsFromCommit(treeWalk, dc, commit, checkout); } } finally { try { @@ -439,7 +441,8 @@ public class CheckoutCommand extends GitCommand { return this; } - private void checkoutPathsFromIndex(TreeWalk treeWalk, DirCache dc) + private void checkoutPathsFromIndex(TreeWalk treeWalk, DirCache dc, + Checkout checkout) throws IOException { DirCacheIterator dci = new DirCacheIterator(dc); treeWalk.addTree(dci); @@ -465,8 +468,9 @@ public class CheckoutCommand extends GitCommand { if (stage > DirCacheEntry.STAGE_0) { if (checkoutStage != null) { if (stage == checkoutStage.number) { - checkoutPath(ent, r, new CheckoutMetadata( - eolStreamType, filterCommand)); + checkoutPath(ent, r, checkout, path, + new CheckoutMetadata(eolStreamType, + filterCommand)); actuallyModifiedPaths.add(path); } } else { @@ -475,8 +479,9 @@ public class CheckoutCommand extends GitCommand { throw new JGitInternalException(e.getMessage(), e); } } else { - checkoutPath(ent, r, new CheckoutMetadata(eolStreamType, - filterCommand)); + checkoutPath(ent, r, checkout, path, + new CheckoutMetadata(eolStreamType, + filterCommand)); actuallyModifiedPaths.add(path); } } @@ -488,7 +493,7 @@ public class CheckoutCommand extends GitCommand { } private void checkoutPathsFromCommit(TreeWalk treeWalk, DirCache dc, - RevCommit commit) throws IOException { + RevCommit commit, Checkout checkout) throws IOException { treeWalk.addTree(commit.getTree()); final ObjectReader r = treeWalk.getObjectReader(); DirCacheEditor editor = dc.editor(); @@ -510,7 +515,7 @@ public class CheckoutCommand extends GitCommand { } ent.setObjectId(blobId); ent.setFileMode(mode); - checkoutPath(ent, r, + checkoutPath(ent, r, checkout, path, new CheckoutMetadata(eolStreamType, filterCommand)); actuallyModifiedPaths.add(path); } @@ -520,10 +525,9 @@ public class CheckoutCommand extends GitCommand { } private void checkoutPath(DirCacheEntry entry, ObjectReader reader, - CheckoutMetadata checkoutMetadata) { + Checkout checkout, String path, CheckoutMetadata checkoutMetadata) { try { - DirCacheCheckout.checkoutEntry(repo, entry, reader, true, - checkoutMetadata); + checkout.checkout(entry, checkoutMetadata, reader, path); } catch (IOException e) { throw new JGitInternalException(MessageFormat.format( JGitText.get().checkoutConflictWithFile, diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java index 1004d3e50f..e4157286f1 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/api/StashApplyCommand.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2012, 2021 GitHub Inc. and others + * Copyright (C) 2012, 2023 GitHub 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 @@ -23,6 +23,7 @@ import org.eclipse.jgit.api.errors.JGitInternalException; import org.eclipse.jgit.api.errors.NoHeadException; import org.eclipse.jgit.api.errors.StashApplyFailureException; import org.eclipse.jgit.api.errors.WrongRepositoryStateException; +import org.eclipse.jgit.dircache.Checkout; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheBuilder; import org.eclipse.jgit.dircache.DirCacheCheckout; @@ -382,6 +383,7 @@ public class StashApplyCommand extends GitCommand { private void resetUntracked(RevTree tree) throws CheckoutConflictException, IOException { Set actuallyModifiedPaths = new HashSet<>(); + Checkout checkout = new Checkout(repo).setRecursiveDeletion(true); // TODO maybe NameConflictTreeWalk ? try (TreeWalk walk = new TreeWalk(repo)) { walk.addTree(tree); @@ -405,17 +407,17 @@ public class StashApplyCommand extends GitCommand { FileTreeIterator fIter = walk .getTree(1, FileTreeIterator.class); + String gitPath = entry.getPathString(); if (fIter != null) { if (fIter.isModified(entry, true, reader)) { // file exists and is dirty - throw new CheckoutConflictException( - entry.getPathString()); + throw new CheckoutConflictException(gitPath); } } - checkoutPath(entry, reader, + checkoutPath(entry, gitPath, reader, checkout, new CheckoutMetadata(eolStreamType, null)); - actuallyModifiedPaths.add(entry.getPathString()); + actuallyModifiedPaths.add(gitPath); } } finally { if (!actuallyModifiedPaths.isEmpty()) { @@ -425,11 +427,11 @@ public class StashApplyCommand extends GitCommand { } } - private void checkoutPath(DirCacheEntry entry, ObjectReader reader, - CheckoutMetadata checkoutMetadata) { + private void checkoutPath(DirCacheEntry entry, String gitPath, + ObjectReader reader, + Checkout checkout, CheckoutMetadata checkoutMetadata) { try { - DirCacheCheckout.checkoutEntry(repo, entry, reader, true, - checkoutMetadata); + checkout.checkout(entry, checkoutMetadata, reader, gitPath); } catch (IOException e) { throw new JGitInternalException(MessageFormat.format( JGitText.get().checkoutConflictWithFile, diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java new file mode 100644 index 0000000000..e1e596ab9d --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/Checkout.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2023, Thomas Wolf 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.dircache; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.StandardCopyOption; +import java.text.MessageFormat; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; +import org.eclipse.jgit.internal.JGitText; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.FileModeCache; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.CoreConfig.EolStreamType; +import org.eclipse.jgit.lib.CoreConfig.SymLinks; +import org.eclipse.jgit.lib.FileModeCache.CacheItem; +import org.eclipse.jgit.treewalk.WorkingTreeOptions; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.FileUtils; +import org.eclipse.jgit.util.RawParseUtils; + +/** + * An object that can be used to check out many files. + * + * @since 5.13.3 + */ +public class Checkout { + + private final FileModeCache cache; + + private final WorkingTreeOptions options; + + private boolean recursiveDelete; + + /** + * Creates a new {@link Checkout} for checking out from the given + * repository. + * + * @param repo + * the {@link Repository} to check out from + */ + public Checkout(@NonNull Repository repo) { + this(repo, null); + } + + /** + * Creates a new {@link Checkout} for checking out from the given + * repository. + * + * @param repo + * the {@link Repository} to check out from + * @param options + * the {@link WorkingTreeOptions} to use; if {@code null}, + * read from the {@code repo} config when this object is + * created + */ + public Checkout(@NonNull Repository repo, WorkingTreeOptions options) { + this.cache = new FileModeCache(repo); + this.options = options != null ? options + : repo.getConfig().get(WorkingTreeOptions.KEY); + } + + /** + * Retrieves the {@link WorkingTreeOptions} of the repository that are + * used. + * + * @return the {@link WorkingTreeOptions} + */ + public WorkingTreeOptions getWorkingTreeOptions() { + return options; + } + + /** + * Defines whether directories that are in the way of the file to be checked + * out shall be deleted recursively. + * + * @param recursive + * whether to delete such directories recursively + * @return {@code this} + */ + public Checkout setRecursiveDeletion(boolean recursive) { + this.recursiveDelete = recursive; + return this; + } + + /** + * Ensure that the given parent directory exists, and cache the information + * that gitPath refers to a file. + * + * @param gitPath + * of the file to be written + * @param parentDir + * directory in which the file shall be placed, assumed to be the + * parent of the {@code gitPath} + * @param makeSpace + * whether to delete a possibly existing file at + * {@code parentDir} + * @throws IOException + * if the directory cannot be created, if necessary + */ + public void safeCreateParentDirectory(String gitPath, File parentDir, + boolean makeSpace) throws IOException { + cache.safeCreateParentDirectory(gitPath, parentDir, makeSpace); + } + + /** + * Checks out the gitlink given by the {@link DirCacheEntry}. + * + * @param entry + * {@link DirCacheEntry} to check out + * @param gitPath + * the git path of the entry, if known already; otherwise + * {@code null} and it's read from the entry itself + * @throws IOException + * if the gitlink cannot be checked out + */ + public void checkoutGitlink(DirCacheEntry entry, String gitPath) + throws IOException { + FS fs = cache.getRepository().getFS(); + File workingTree = cache.getRepository().getWorkTree(); + String path = gitPath != null ? gitPath : entry.getPathString(); + File gitlinkDir = new File(workingTree, path); + File parentDir = gitlinkDir.getParentFile(); + CacheItem cachedParent = cache.safeCreateDirectory(path, parentDir, + false); + FileUtils.mkdirs(gitlinkDir, true); + cachedParent.insert(path.substring(path.lastIndexOf('/') + 1), + FileMode.GITLINK); + entry.setLastModified(fs.lastModifiedInstant(gitlinkDir)); + } + + /** + * Checks out the file given by the {@link DirCacheEntry}. + * + * @param entry + * {@link DirCacheEntry} to check out + * @param metadata + * {@link CheckoutMetadata} to use for CR/LF handling and + * smudge filtering + * @param reader + * {@link ObjectReader} to use + * @param gitPath + * the git path of the entry, if known already; otherwise + * {@code null} and it's read from the entry itself + * @throws IOException + * if the file cannot be checked out + */ + public void checkout(DirCacheEntry entry, CheckoutMetadata metadata, + ObjectReader reader, String gitPath) throws IOException { + if (metadata == null) { + metadata = CheckoutMetadata.EMPTY; + } + FS fs = cache.getRepository().getFS(); + ObjectLoader ol = reader.open(entry.getObjectId()); + String path = gitPath != null ? gitPath : entry.getPathString(); + File f = new File(cache.getRepository().getWorkTree(), path); + File parentDir = f.getParentFile(); + CacheItem cachedParent = cache.safeCreateDirectory(path, parentDir, + true); + if (entry.getFileMode() == FileMode.SYMLINK + && options.getSymLinks() == SymLinks.TRUE) { + byte[] bytes = ol.getBytes(); + String target = RawParseUtils.decode(bytes); + if (recursiveDelete && Files.isDirectory(f.toPath(), + LinkOption.NOFOLLOW_LINKS)) { + FileUtils.delete(f, FileUtils.RECURSIVE); + } + fs.createSymLink(f, target); + cachedParent.insert(f.getName(), FileMode.SYMLINK); + entry.setLength(bytes.length); + entry.setLastModified(fs.lastModifiedInstant(f)); + return; + } + + String name = f.getName(); + if (name.length() > 200) { + name = name.substring(0, 200); + } + File tmpFile = File.createTempFile("._" + name, null, parentDir); //$NON-NLS-1$ + + DirCacheCheckout.getContent(cache.getRepository(), path, metadata, ol, + options, + new FileOutputStream(tmpFile)); + + // The entry needs to correspond to the on-disk file size. If the + // content was filtered (either by autocrlf handling or smudge + // filters) ask the file system again for the length. Otherwise the + // object loader knows the size + if (metadata.eolStreamType == EolStreamType.DIRECT + && metadata.smudgeFilterCommand == null) { + entry.setLength(ol.getSize()); + } else { + entry.setLength(tmpFile.length()); + } + + if (options.isFileMode() && fs.supportsExecute()) { + if (FileMode.EXECUTABLE_FILE.equals(entry.getRawMode())) { + if (!fs.canExecute(tmpFile)) + fs.setExecute(tmpFile, true); + } else { + if (fs.canExecute(tmpFile)) + fs.setExecute(tmpFile, false); + } + } + try { + if (recursiveDelete && Files.isDirectory(f.toPath(), + LinkOption.NOFOLLOW_LINKS)) { + FileUtils.delete(f, FileUtils.RECURSIVE); + } + FileUtils.rename(tmpFile, f, StandardCopyOption.ATOMIC_MOVE); + cachedParent.remove(f.getName()); + } catch (IOException e) { + throw new IOException( + MessageFormat.format(JGitText.get().renameFileFailed, + tmpFile.getPath(), f.getPath()), + e); + } finally { + if (tmpFile.exists()) { + FileUtils.delete(tmpFile); + } + } + entry.setLastModified(fs.lastModifiedInstant(f)); + } +} \ No newline at end of file diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java index c904a782db..3d94547017 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/dircache/DirCacheCheckout.java @@ -4,7 +4,8 @@ * Copyright (C) 2008, Roger C. Soares * Copyright (C) 2006, Shawn O. Pearce * Copyright (C) 2010, Chrisian Halstrick - * Copyright (C) 2019-2020, Andre Bossert + * Copyright (C) 2019, 2020, Andre Bossert + * Copyright (C) 2017, 2023, Thomas Wolf 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 @@ -18,10 +19,8 @@ package org.eclipse.jgit.dircache; import static org.eclipse.jgit.treewalk.TreeWalk.OperationType.CHECKOUT_OP; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; -import java.nio.file.StandardCopyOption; import java.text.MessageFormat; import java.time.Instant; import java.util.ArrayList; @@ -47,7 +46,6 @@ import org.eclipse.jgit.lib.ConfigConstants; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.CoreConfig.AutoCRLF; import org.eclipse.jgit.lib.CoreConfig.EolStreamType; -import org.eclipse.jgit.lib.CoreConfig.SymLinks; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.NullProgressMonitor; import org.eclipse.jgit.lib.ObjectChecker; @@ -67,9 +65,7 @@ import org.eclipse.jgit.treewalk.WorkingTreeOptions; import org.eclipse.jgit.treewalk.filter.PathFilter; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.FS.ExecutionResult; -import org.eclipse.jgit.util.FileUtils; import org.eclipse.jgit.util.IntList; -import org.eclipse.jgit.util.RawParseUtils; import org.eclipse.jgit.util.SystemReader; import org.eclipse.jgit.util.io.EolStreamTypeUtil; import org.slf4j.Logger; @@ -142,6 +138,8 @@ public class DirCacheCheckout { private boolean performingCheckout; + private Checkout checkout; + private ProgressMonitor monitor = NullProgressMonitor.INSTANCE; /** @@ -492,6 +490,7 @@ public class DirCacheCheckout { CheckoutConflictException, IndexWriteException, CanceledException { toBeDeleted.clear(); try (ObjectReader objectReader = repo.getObjectDatabase().newReader()) { + checkout = new Checkout(repo, null); if (headCommitTree != null) preScanTwoTrees(); else @@ -558,9 +557,9 @@ public class DirCacheCheckout { CheckoutMetadata meta = e.getValue(); DirCacheEntry entry = dc.getEntry(path); if (FileMode.GITLINK.equals(entry.getRawMode())) { - checkoutGitlink(path, entry); + checkout.checkoutGitlink(entry, path); } else { - checkoutEntry(repo, entry, objectReader, false, meta); + checkout.checkout(entry, meta, objectReader, path); } e = null; @@ -595,8 +594,8 @@ public class DirCacheCheckout { break; } if (entry.getStage() == DirCacheEntry.STAGE_3) { - checkoutEntry(repo, entry, objectReader, false, - null); + checkout.checkout(entry, null, objectReader, + conflict); break; } ++entryIdx; @@ -619,14 +618,6 @@ public class DirCacheCheckout { return toBeDeleted.isEmpty(); } - private void checkoutGitlink(String path, DirCacheEntry entry) - throws IOException { - File gitlinkDir = new File(repo.getWorkTree(), path); - FileUtils.mkdirs(gitlinkDir, true); - FS fs = repo.getFS(); - entry.setLastModified(fs.lastModifiedInstant(gitlinkDir)); - } - private static ArrayList filterOut(ArrayList strings, IntList indicesToRemove) { int n = indicesToRemove.size(); @@ -1225,10 +1216,11 @@ public class DirCacheCheckout { if (force) { if (f == null || f.isModified(e, true, walk.getObjectReader())) { kept.add(path); - checkoutEntry(repo, e, walk.getObjectReader(), false, + checkout.checkout(e, new CheckoutMetadata(walk.getEolStreamType(CHECKOUT_OP), walk.getFilterCommand( - Constants.ATTR_FILTER_TYPE_SMUDGE))); + Constants.ATTR_FILTER_TYPE_SMUDGE)), + walk.getObjectReader(), path); } } } @@ -1453,76 +1445,9 @@ public class DirCacheCheckout { public static void checkoutEntry(Repository repo, DirCacheEntry entry, ObjectReader or, boolean deleteRecursive, CheckoutMetadata checkoutMetadata) throws IOException { - if (checkoutMetadata == null) - checkoutMetadata = CheckoutMetadata.EMPTY; - ObjectLoader ol = or.open(entry.getObjectId()); - File f = new File(repo.getWorkTree(), entry.getPathString()); - File parentDir = f.getParentFile(); - if (parentDir.isFile()) { - FileUtils.delete(parentDir); - } - FileUtils.mkdirs(parentDir, true); - FS fs = repo.getFS(); - WorkingTreeOptions opt = repo.getConfig().get(WorkingTreeOptions.KEY); - if (entry.getFileMode() == FileMode.SYMLINK - && opt.getSymLinks() == SymLinks.TRUE) { - byte[] bytes = ol.getBytes(); - String target = RawParseUtils.decode(bytes); - if (deleteRecursive && f.isDirectory()) { - FileUtils.delete(f, FileUtils.RECURSIVE); - } - fs.createSymLink(f, target); - entry.setLength(bytes.length); - entry.setLastModified(fs.lastModifiedInstant(f)); - return; - } - - String name = f.getName(); - if (name.length() > 200) { - name = name.substring(0, 200); - } - File tmpFile = File.createTempFile( - "._" + name, null, parentDir); //$NON-NLS-1$ - - getContent(repo, entry.getPathString(), checkoutMetadata, ol, opt, - new FileOutputStream(tmpFile)); - - // The entry needs to correspond to the on-disk filesize. If the content - // was filtered (either by autocrlf handling or smudge filters) ask the - // filesystem again for the length. Otherwise the objectloader knows the - // size - if (checkoutMetadata.eolStreamType == EolStreamType.DIRECT - && checkoutMetadata.smudgeFilterCommand == null) { - entry.setLength(ol.getSize()); - } else { - entry.setLength(tmpFile.length()); - } - - if (opt.isFileMode() && fs.supportsExecute()) { - if (FileMode.EXECUTABLE_FILE.equals(entry.getRawMode())) { - if (!fs.canExecute(tmpFile)) - fs.setExecute(tmpFile, true); - } else { - if (fs.canExecute(tmpFile)) - fs.setExecute(tmpFile, false); - } - } - try { - if (deleteRecursive && f.isDirectory()) { - FileUtils.delete(f, FileUtils.RECURSIVE); - } - FileUtils.rename(tmpFile, f, StandardCopyOption.ATOMIC_MOVE); - } catch (IOException e) { - throw new IOException( - MessageFormat.format(JGitText.get().renameFileFailed, - tmpFile.getPath(), f.getPath()), - e); - } finally { - if (tmpFile.exists()) { - FileUtils.delete(tmpFile); - } - } - entry.setLastModified(fs.lastModifiedInstant(f)); + Checkout checkout = new Checkout(repo, null) + .setRecursiveDeletion(deleteRecursive); + checkout.checkout(entry, checkoutMetadata, or, null); } /** diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java index 446d35078f..28604917a5 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java @@ -44,6 +44,8 @@ public class JGitText extends TranslationBundle { /***/ public String applyBinaryBaseOidWrong; /***/ public String applyBinaryOidTooShort; /***/ public String applyBinaryResultOidWrong; + /***/ public String applyPatchDestInvalid; + /***/ public String applyPatchSourceInvalid; /***/ public String applyingCommit; /***/ public String archiveFormatAlreadyAbsent; /***/ public String archiveFormatAlreadyRegistered; @@ -578,6 +580,8 @@ public class JGitText extends TranslationBundle { /***/ public String packWriterStatistics; /***/ public String panicCantRenameIndexFile; /***/ public String patchApplyException; + /***/ public String patchApplyErrorWithHunk; + /***/ public String patchApplyErrorWithoutHunk; /***/ public String patchFormatException; /***/ public String pathNotConfigured; /***/ public String peeledLineBeforeRef; diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java b/org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java new file mode 100644 index 0000000000..349d1270f2 --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/lib/FileModeCache.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2023, Thomas Wolf 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.lib; + +import java.io.File; +import java.io.IOException; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.FileUtils; + +/** + * A hierarchical cache of {@link FileMode}s per git path. + * + * @since 5.13.3 + */ +public class FileModeCache { + + @NonNull + private final CacheItem root = new CacheItem(FileMode.TREE); + + @NonNull + private final Repository repo; + + /** + * Creates a new {@link FileModeCache} for a {@link Repository}. + * + * @param repo + * {@link Repository} this cache is for + */ + public FileModeCache(@NonNull Repository repo) { + this.repo = repo; + } + + /** + * Retrieves the {@link Repository}. + * + * @return the {@link Repository} this {@link FileModeCache} was created for + */ + @NonNull + public Repository getRepository() { + return repo; + } + + /** + * Obtains the {@link CacheItem} for the working tree root. + * + * @return the {@link CacheItem} + */ + @NonNull + public CacheItem getRoot() { + return root; + } + + /** + * Ensure that the given parent directory exists, and cache the information + * that gitPath refers to a file. + * + * @param gitPath + * of the file to be written + * @param parentDir + * directory in which the file shall be placed, assumed to be the + * parent of the {@code gitPath} + * @param makeSpace + * whether to delete a possibly existing file at + * {@code parentDir} + * @throws IOException + * if the directory cannot be created, if necessary + */ + public void safeCreateParentDirectory(String gitPath, File parentDir, + boolean makeSpace) throws IOException { + CacheItem cachedParent = safeCreateDirectory(gitPath, parentDir, + makeSpace); + cachedParent.remove(gitPath.substring(gitPath.lastIndexOf('/') + 1)); + } + + /** + * Ensures the given directory {@code dir} with the given git path exists. + * + * @param gitPath + * of a file to be written + * @param dir + * directory in which the file shall be placed, assumed to be the + * parent of the {@code gitPath} + * @param makeSpace + * whether to remove a file that already at that name + * @return A {@link CacheItem} describing the directory, which is guaranteed + * to exist + * @throws IOException + * if the directory cannot be made to exist at the given + * location + */ + public CacheItem safeCreateDirectory(String gitPath, File dir, + boolean makeSpace) throws IOException { + FS fs = repo.getFS(); + int i = gitPath.lastIndexOf('/'); + String parentPath = null; + if (i >= 0) { + if ((makeSpace && dir.isFile()) || fs.isSymLink(dir)) { + FileUtils.delete(dir); + } + parentPath = gitPath.substring(0, i); + deleteSymlinkParent(fs, parentPath, repo.getWorkTree()); + } + FileUtils.mkdirs(dir, true); + CacheItem cachedParent = getRoot(); + if (parentPath != null) { + cachedParent = add(parentPath, FileMode.TREE); + } + return cachedParent; + } + + private void deleteSymlinkParent(FS fs, String gitPath, File workingTree) + throws IOException { + if (!fs.supportsSymlinks()) { + return; + } + String[] parts = gitPath.split("/"); //$NON-NLS-1$ + int n = parts.length; + CacheItem cached = getRoot(); + File p = workingTree; + for (int i = 0; i < n; i++) { + p = new File(p, parts[i]); + CacheItem cachedChild = cached != null ? cached.child(parts[i]) + : null; + boolean delete = false; + if (cachedChild != null) { + if (FileMode.SYMLINK.equals(cachedChild.getMode())) { + delete = true; + } + } else { + try { + Path nioPath = FileUtils.toPath(p); + BasicFileAttributes attributes = nioPath.getFileSystem() + .provider() + .getFileAttributeView(nioPath, + BasicFileAttributeView.class, + LinkOption.NOFOLLOW_LINKS) + .readAttributes(); + if (attributes.isSymbolicLink()) { + delete = p.isDirectory(); + } else if (attributes.isRegularFile()) { + break; + } + } catch (InvalidPathException | IOException e) { + // If we can't get the attributes the path does not exist, + // or if it does a subsequent mkdirs() will also throw an + // exception. + break; + } + } + if (delete) { + // Deletes the symlink + FileUtils.delete(p, FileUtils.SKIP_MISSING); + if (cached != null) { + cached.remove(parts[i]); + } + break; + } + cached = cachedChild; + } + } + + /** + * Records the given {@link FileMode} for the given git path in the cache. + * If an entry already exists for the given path, the previously cached file + * mode is overwritten. + * + * @param gitPath + * to cache the {@link FileMode} for + * @param finalMode + * {@link FileMode} to cache + * @return the {@link CacheItem} for the path + */ + @NonNull + private CacheItem add(String gitPath, FileMode finalMode) { + if (gitPath.isEmpty()) { + throw new IllegalArgumentException(); + } + String[] parts = gitPath.split("/"); //$NON-NLS-1$ + int n = parts.length; + int i = 0; + CacheItem curr = getRoot(); + while (i < n) { + CacheItem next = curr.child(parts[i]); + if (next == null) { + break; + } + curr = next; + i++; + } + if (i == n) { + curr.setMode(finalMode); + } else { + while (i < n) { + curr = curr.insert(parts[i], + i + 1 == n ? finalMode : FileMode.TREE); + i++; + } + } + return curr; + } + + /** + * An item from a {@link FileModeCache}, recording information about a git + * path (known from context). + */ + public static class CacheItem { + + @NonNull + private FileMode mode; + + private Map children; + + /** + * Creates a new {@link CacheItem}. + * + * @param mode + * {@link FileMode} to cache + */ + public CacheItem(@NonNull FileMode mode) { + this.mode = mode; + } + + /** + * Retrieves the cached {@link FileMode}. + * + * @return the {@link FileMode} + */ + @NonNull + public FileMode getMode() { + return mode; + } + + /** + * Retrieves an immediate child of this {@link CacheItem} by name. + * + * @param childName + * name of the child to get + * @return the {@link CacheItem}, or {@code null} if no such child is + * known + */ + public CacheItem child(String childName) { + if (children == null) { + return null; + } + return children.get(childName); + } + + /** + * Inserts a new cached {@link FileMode} as an immediate child of this + * {@link CacheItem}. If there is already a child with the same name, it + * is overwritten. + * + * @param childName + * name of the child to create + * @param childMode + * {@link FileMode} to cache + * @return the new {@link CacheItem} created for the child + */ + public CacheItem insert(String childName, @NonNull FileMode childMode) { + if (!FileMode.TREE.equals(mode)) { + throw new IllegalArgumentException(); + } + if (children == null) { + children = new HashMap<>(); + } + CacheItem newItem = new CacheItem(childMode); + children.put(childName, newItem); + return newItem; + } + + /** + * Removes the immediate child with the given name. + * + * @param childName + * name of the child to remove + * @return the previously cached {@link CacheItem}, if any + */ + public CacheItem remove(String childName) { + if (children == null) { + return null; + } + return children.remove(childName); + } + + void setMode(@NonNull FileMode mode) { + this.mode = mode; + if (!FileMode.TREE.equals(mode)) { + children = null; + } + } + } + +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java index 7767662867..72065fbc57 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/merge/ResolveMerger.java @@ -3,7 +3,7 @@ * Copyright (C) 2010-2012, Matthias Sohn * Copyright (C) 2012, Research In Motion Limited * Copyright (C) 2017, Obeo (mathieu.cartaud@obeo.fr) - * Copyright (C) 2018, Thomas Wolf and others + * Copyright (C) 2018, 2023 Thomas Wolf * * This program and the accompanying materials are made available under the * terms of the Eclipse Distribution License v. 1.0 which is available at @@ -44,10 +44,10 @@ import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm; import org.eclipse.jgit.diff.RawText; import org.eclipse.jgit.diff.RawTextComparator; import org.eclipse.jgit.diff.Sequence; +import org.eclipse.jgit.dircache.Checkout; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheBuildIterator; import org.eclipse.jgit.dircache.DirCacheBuilder; -import org.eclipse.jgit.dircache.DirCacheCheckout; import org.eclipse.jgit.dircache.DirCacheCheckout.CheckoutMetadata; import org.eclipse.jgit.dircache.DirCacheEntry; import org.eclipse.jgit.errors.BinaryBlobException; @@ -76,7 +76,6 @@ import org.eclipse.jgit.treewalk.TreeWalk.OperationType; import org.eclipse.jgit.treewalk.WorkingTreeIterator; import org.eclipse.jgit.treewalk.WorkingTreeOptions; import org.eclipse.jgit.treewalk.filter.TreeFilter; -import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.LfsFactory; import org.eclipse.jgit.util.LfsFactory.LfsInputStream; import org.eclipse.jgit.util.TemporaryBuffer; @@ -86,6 +85,13 @@ import org.eclipse.jgit.util.io.EolStreamTypeUtil; * A three-way merger performing a content-merge if necessary */ public class ResolveMerger extends ThreeWayMerger { + + /** + * {@link Checkout} to use for actually checking out files if + * {@link #inCore} is {@code false}. + */ + private Checkout checkout; + /** * If the merge fails (means: not stopped because of unresolved conflicts) * this enum is used to explain why it failed @@ -322,6 +328,7 @@ public class ResolveMerger extends ThreeWayMerger { implicitDirCache = true; workingTreeOptions = local.getConfig().get(WorkingTreeOptions.KEY); } + checkout = new Checkout(nonNullRepo(), workingTreeOptions); } /** @@ -411,12 +418,14 @@ public class ResolveMerger extends ThreeWayMerger { for (Map.Entry entry : toBeCheckedOut .entrySet()) { DirCacheEntry cacheEntry = entry.getValue(); + String gitPath = entry.getKey(); if (cacheEntry.getFileMode() == FileMode.GITLINK) { - new File(nonNullRepo().getWorkTree(), entry.getKey()).mkdirs(); + checkout.checkoutGitlink(cacheEntry, gitPath); } else { - DirCacheCheckout.checkoutEntry(db, cacheEntry, reader, false, - checkoutMetadata.get(entry.getKey())); - modifiedFiles.add(entry.getKey()); + checkout.checkout(cacheEntry, + checkoutMetadata.get(entry.getKey()), reader, + gitPath); + modifiedFiles.add(gitPath); } } } @@ -446,8 +455,8 @@ public class ResolveMerger extends ThreeWayMerger { String mpath = mpathsIt.next(); DirCacheEntry entry = dc.getEntry(mpath); if (entry != null) { - DirCacheCheckout.checkoutEntry(db, entry, reader, false, - checkoutMetadata.get(mpath)); + checkout.checkout(entry, checkoutMetadata.get(mpath), + reader, mpath); } mpathsIt.remove(); } @@ -1076,15 +1085,12 @@ public class ResolveMerger extends ThreeWayMerger { Attributes attributes) throws FileNotFoundException, IOException { File workTree = nonNullRepo().getWorkTree(); - FS fs = nonNullRepo().getFS(); - File of = new File(workTree, tw.getPathString()); - File parentFolder = of.getParentFile(); - if (!fs.exists(parentFolder)) { - parentFolder.mkdirs(); - } + String gitPath = tw.getPathString(); + File of = new File(workTree, gitPath); EolStreamType streamType = EolStreamTypeUtil.detectStreamType( OperationType.CHECKOUT_OP, workingTreeOptions, attributes); + checkout.safeCreateParentDirectory(gitPath, of.getParentFile(), false); try (OutputStream os = EolStreamTypeUtil.wrapOutputStream( new BufferedOutputStream(new FileOutputStream(of)), streamType)) { @@ -1362,9 +1368,9 @@ public class ResolveMerger extends ThreeWayMerger { // go into the new index. checkout(); - // All content-merges are successfully done. If we can now write the - // new index we are on quite safe ground. Even if the checkout of - // files coming from "theirs" fails the user can work around such + // All content-merges are successfully done. If we can now write + // the new index we are on quite safe ground. Even if the checkout + // of files coming from "theirs" fails the user can work around such // failures by checking out the index again. if (!builder.commit()) { cleanUp(); -- 2.39.5