From c9702656bbc11f1284e69def71c903b1c940b941 Mon Sep 17 00:00:00 2001 From: "CORP\\mmrzik" Date: Mon, 13 Nov 2023 14:19:04 +0100 Subject: [PATCH] #139: relative symlink feature --- .../com/devonfw/tools/ide/io/FileAccess.java | 26 ++ .../devonfw/tools/ide/io/FileAccessImpl.java | 71 +++++- .../tools/ide/io/FileAccessImplTest.java | 234 ++++++++++++++++++ .../ide/version/VersionIdentifierTest.java | 2 +- 4 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccess.java b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccess.java index 82fe25090..6864f7269 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccess.java +++ b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccess.java @@ -51,6 +51,32 @@ public interface FileAccess { */ void move(Path source, Path targetDir); + /** + * Symbolic links can point to relative or absolute paths. Here the link is converted to be relative. If the target of + * the link is again a link, then that lead is followed, until the target is not a link. + * + * @param link the {@link Path} of the symbolic link. + */ + void makeSymlinkRelative(Path link); + + /** + * Symbolic links can point to relative or absolute paths. Here the link is converted to be relative. + * + * @param link the {@link Path} of the symbolic link. + * @param followTarget - {@code true} if the target of the link is again a link, then that lead is followed, until the + * target is not a link. - {@code false} if the target of the link is again a link, then that lead is no + * followed. + */ + void makeSymlinkRelative(Path link, boolean followTarget); + + /** + * Creates a symbolic relative link. + * + * @param source the source {@link Path} to link to. + * @param targetLink the {@link Path} where the symbolic link shall be created pointing to {@code source}. + */ + void relativeSymlink(Path targetLink, Path source); + /** * @param source the source {@link Path} to link to. * @param targetLink the {@link Path} where the symbolic link shall be created pointing to {@code source}. diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java index 3a916fcd1..87e527fb8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/io/FileAccessImpl.java @@ -15,8 +15,10 @@ import java.net.http.HttpResponse; import java.nio.file.FileSystemException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; import java.security.DigestInputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -282,22 +284,74 @@ private void copyRecursive(Path source, Path target, FileCopyMode mode) throws I } } + @Override + public void makeSymlinkRelative(Path link) { + + makeSymlinkRelative(link, false); + } + + @Override + public void makeSymlinkRelative(Path link, boolean followTarget) { + + if (!Files.isSymbolicLink(link)) { + throw new IllegalStateException( + "Can't call makeSymlinkRelative on " + link + " since it is not a symbolic link."); + } + Path linkTarget = null; + try { + linkTarget = followTarget ? link.toRealPath() : Files.readSymbolicLink(link); + } catch (IOException e) { + throw new RuntimeException("For link " + link + " the call to " + + (followTarget ? "toRealPath" : "readSymbolicLink") + " in method makeSymlinkRelative failed.", e); + } + this.context.getFileAccess().delete(link); // delete old absolute link + this.context.getFileAccess().relativeSymlink(link, linkTarget); // and replace it by the new relative link + } + + @Override + public void relativeSymlink(Path link, Path source) { + + Path relativeSource = link.getParent().relativize(source); + // to make relative links like this work: dir/link -> dir + relativeSource = (relativeSource.toString().isEmpty()) ? Paths.get(".") : relativeSource; + symlink(relativeSource, link); + } + @Override public void symlink(Path source, Path targetLink) { this.context.trace("Creating symbolic link {} pointing to {}", targetLink, source); try { - if (Files.exists(targetLink) && Files.isSymbolicLink(targetLink)) { - this.context.debug("Deleting symbolic link to be re-created at {}", targetLink); - Files.delete(targetLink); + if (Files.exists(targetLink)) { + if (Files.isSymbolicLink(targetLink)) { + this.context.debug("Deleting symbolic link to be re-created at {}", targetLink); + Files.delete(targetLink); + } else { + BasicFileAttributes attr = Files.readAttributes(targetLink, BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS); + if (attr.isOther() && attr.isDirectory()) { + this.context.debug("Deleting symbolic link (junction) to be re-created at {}", targetLink); + Files.delete(targetLink); + } + } } Files.createSymbolicLink(targetLink, source); } catch (FileSystemException e) { if (this.context.getSystemInfo().isWindows()) { - info( - "Due to lack of permissions, Microsofts mklink with junction had to be used to create a Symlink. See https://github.com/devonfw/IDEasy/blob/main/documentation/symlinks.asciidoc for further details. Error was: " - + e.getMessage()); - + String infoMsg = "Due to lack of permissions, Microsofts mklink with junction had to be used to create " + + "a Symlink. See https://github.com/devonfw/IDEasy/blob/main/documentation/symlinks.asciidoc for " + + "further details. Error was: " + e.getMessage(); + info(infoMsg); + if (!source.isAbsolute()) { + throw new IllegalStateException( + infoMsg + "\\n These junctions can only point to absolute paths. Please make sure that the targetLink (" + + targetLink + ") is absolute."); + } + if (!Files.isDirectory(source)) { // if source is a junction. This returns true as well. + throw new IllegalStateException(infoMsg + + "\\n These junctions can only point to directories or other junctions. Please make sure that the source (" + + source + ") is one of these."); + } context.newProcess().executable("cmd") .addArgs("/c", "mklink", "/d", "/j", targetLink.toString(), source.toString()).run(); } else { @@ -391,8 +445,9 @@ public void delete(Path path) { try { if (Files.isSymbolicLink(path)) { Files.delete(path); + } else { + deleteRecursive(path); } - deleteRecursive(path); } catch (IOException e) { throw new IllegalStateException("Failed to delete " + path, e); } diff --git a/cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java b/cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java new file mode 100644 index 000000000..d8096c3c3 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/io/FileAccessImplTest.java @@ -0,0 +1,234 @@ +package com.devonfw.tools.ide.io; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.function.Function; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.context.IdeTestContextMock; + +public class FileAccessImplTest extends Assertions { + + void arrangetestRelativeSymlinks(Path tempDir, FileAccess fileAccess) { + + } + + @Test + void testSymlink(@TempDir Path tempDir) { + + IdeContext context = IdeTestContextMock.get(); + FileAccess fileAccess = new FileAccessImpl(context); + // create a new directory + Path dir = tempDir.resolve("dir"); + fileAccess.mkdirs(dir); + + // create a new file using nio + Path file = tempDir.resolve("file"); + try { + Files.write(file, "Hello World!".getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // try to create a symlink to the file using Files.createSymbolicLink + Path link = tempDir.resolve("link"); + Path linkToLink = tempDir.resolve("linkToLink"); + + boolean junctionsUsed = false; + try { + Files.createSymbolicLink(link, file); + } catch (IOException e) { // if permission is not hold, then junctions are used instead of symlinks (on Windows) + if (context.getSystemInfo().isWindows()) { + junctionsUsed = true; + // this should work + fileAccess.symlink(dir, link); + fileAccess.symlink(dir, link); // should work again + fileAccess.symlink(link, linkToLink); + + IllegalStateException e1 = assertThrows(IllegalStateException.class, () -> { + fileAccess.symlink(file, link); + }); + assertThat(e1).hasMessageContaining("These junctions can only point to directories or other junctions"); + + IllegalStateException e2 = assertThrows(IllegalStateException.class, () -> { + fileAccess.symlink(Paths.get("dir"), link); + }); + assertThat(e2).hasMessageContaining("These junctions can only point to absolute paths"); + } else { + throw new RuntimeException( + "Creating symbolic link with Files.createSymbolicLink failed and junctions can not be used since the OS is not windows: " + + e.getMessage()); + } + } + + // test for normal symlinks (not junctions) + if (!junctionsUsed) { + try { + fileAccess.symlink(file, link); // should work again + fileAccess.symlink(link, linkToLink); + } catch (Exception e) { + fail("Creating symbolic links failed: " + e.getMessage()); + } + try { + assertEquals(linkToLink.toRealPath(), file); + assertEquals(Files.readSymbolicLink(linkToLink), link); + } catch (IOException e) { + fail("Reading symbolic links failed: " + e.getMessage()); + } + } + } + + @Test + void testMakeSymlinkRelative(@TempDir Path tempDir) { + + // arrange + IdeContext context = IdeTestContextMock.get(); + FileAccess fileAccess = new FileAccessImpl(context); + Path parent = tempDir.resolve("parent"); + Path d1 = parent.resolve("d1"); + Path d11 = d1.resolve("d11"); + Path d111 = d11.resolve("d111"); + Path d1111 = d111.resolve("d1111"); + Path d2 = parent.resolve("d2"); + Path d22 = d2.resolve("d22"); + Path d222 = d22.resolve("d222"); + Path[] dirPaths = new Path[] { parent, d1, d11, d111, d1111, d2, d22, d222 }; + for (Path dirPath : dirPaths) { + fileAccess.mkdirs(dirPath); + } + Path link_d11_d1 = d11.resolve("link_d11_d1"); + fileAccess.symlink(d1, link_d11_d1); + + Path link_d11_d11 = d11.resolve("link_d11_d11"); + fileAccess.symlink(d11, link_d11_d11); + + Path link_d11_d111 = d11.resolve("link_d11_d111"); + fileAccess.symlink(d111, link_d11_d111); + + Path link_d11_d1111 = d11.resolve("link_d11_d1111"); + fileAccess.symlink(d1111, link_d11_d1111); + + Path link_d11_d2 = d11.resolve("link_d11_d2"); + fileAccess.symlink(d2, link_d11_d2); + + Path link_d11_d22 = d11.resolve("link_d11_d22"); + fileAccess.symlink(d22, link_d11_d22); + + Path link_d11_d222 = d11.resolve("link_d11_d222"); + fileAccess.symlink(d222, link_d11_d222); + + Path link_d22_link_d11_d1 = d22.resolve("link_d22_link_d11_d1"); + fileAccess.symlink(link_d11_d1, link_d22_link_d11_d1); + + Path link_d2_link_d11_d1 = d2.resolve("link_d2_link_d11_d1"); + fileAccess.symlink(link_d11_d1, link_d2_link_d11_d1); + + Path link_parent_link_d2_link_d11_d1 = parent.resolve("link_parent_link_d2_link_d11_d1"); + fileAccess.symlink(link_d2_link_d11_d1, link_parent_link_d2_link_d11_d1); + + Path[] links = new Path[] { link_d11_d1, link_d11_d11, link_d11_d111, link_d11_d1111, link_d11_d2, link_d11_d22, + link_d11_d222, link_d22_link_d11_d1, link_d2_link_d11_d1, link_parent_link_d2_link_d11_d1 }; + + // act: check if moving breaks absolute symlinks + Path parent2 = tempDir.resolve("parent2"); + fileAccess.move(parent, parent2); + + // assert: check if moving breaks absolute symlinks + Function transformPath = path -> { + String newPath = path.toString().replace("_parent_", "_par_").replace("parent", "parent2").replace("_par_", + "_parent_"); + return Paths.get(newPath); + }; + for (Path link : links) { + try { + Path linkInParent2 = transformPath.apply(link); + assertThat(linkInParent2).existsNoFollowLinks(); + Path realPath = linkInParent2.toRealPath(); + if (Files.exists(realPath)) { + fail("The link target " + realPath + " (from toRealPath) should not exist"); + } + Path readPath = Files.readSymbolicLink(linkInParent2); + if (!Files.exists(readPath)) { + fail("The link target " + readPath + " (from readSymbolicLink) should not exist"); + } + } catch (IOException e) { + assertThat(e).isInstanceOf(IOException.class); + } + } + + // assert: Can't call makeSymlinkRelative since it is not a symbolic link + IllegalStateException e1 = assertThrows(IllegalStateException.class, () -> { + fileAccess.makeSymlinkRelative(d1); + }); + assertThat(e1).hasMessageContaining("is not a symbolic link"); + + boolean junctionsUsed = false; + try { + Files.createSymbolicLink(tempDir.resolve("my_test_link"), parent2); + } catch (IOException e) { + if (!context.getSystemInfo().isWindows()) { + fail("Creating symbolic link with Files.createSymbolicLink failed and junctions can not be used" + + " since the OS is not windows: " + e.getMessage()); + } + junctionsUsed = true; + IllegalStateException e2 = assertThrows(IllegalStateException.class, () -> { + fileAccess.makeSymlinkRelative(link_d2_link_d11_d1); + }); + assertThat(e2).hasMessageContaining("is not a symbolic link"); + } + + // act: make symlinks relative and move + fileAccess.move(parent2, parent); // revert previous move + if (!junctionsUsed) { + for (Path link : links) { + if (link.equals(link_d2_link_d11_d1)) { + fileAccess.makeSymlinkRelative(link, false); + } else { + fileAccess.makeSymlinkRelative(link, true); + } + } + + // redo move, and check later if symlinks still work + fileAccess.move(parent, parent2); + + // assert + for (Path link : links) { + Path linkInParent2 = transformPath.apply(link); + if (link.equals(link_d2_link_d11_d1)) { + try { // checking if the transformation of absolute to relative path with flag followTarget=false works + Path correct = transformPath.apply(link_d11_d1); + assertEquals(correct, linkInParent2.getParent().resolve(Files.readSymbolicLink(linkInParent2)) + .toRealPath(LinkOption.NOFOLLOW_LINKS)); + } catch (IOException e) { + throw new RuntimeException("Couldn't get path of link where followTarget was set to false: ", e); + } + } + assertThat(linkInParent2).existsNoFollowLinks(); + try { + Path realPath = linkInParent2.toRealPath(); + assertThat(realPath).existsNoFollowLinks(); + assertThat(realPath).exists(); + } catch (IOException e) { + throw new RuntimeException("Could not call toRealPath on moved relative link: " + linkInParent2, e); + } + try { + Path readPath = Files.readSymbolicLink(linkInParent2); + assertThat(linkInParent2.getParent().resolve(readPath)).existsNoFollowLinks(); + assertThat(linkInParent2.getParent().resolve(readPath)).exists(); + } catch (IOException e) { + throw new RuntimeException("Could not call Files.readSymbolicLink on moved relative link: " + linkInParent2, + e); + } + } + } + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java b/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java index 9256e6e7d..20fa0ed8d 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java @@ -93,7 +93,7 @@ public void testIllegal() { for (String version : illegalVersions) { try { VersionIdentifier.of(version); - fail("Illegal verion '" + version + "' did not cause an exception!"); + fail("Illegal version '" + version + "' did not cause an exception!"); } catch (Exception e) { assertThat(e).isInstanceOf(IllegalArgumentException.class); assertThat(e).hasMessageContaining(version);