diff --git a/platform/util/src/com/intellij/openapi/util/io/FileUtil.java b/platform/util/src/com/intellij/openapi/util/io/FileUtil.java
index 7ad8343fc52d..e48f1b4d995f 100644
--- a/platform/util/src/com/intellij/openapi/util/io/FileUtil.java
+++ b/platform/util/src/com/intellij/openapi/util/io/FileUtil.java
@@ -645,13 +645,35 @@ public class FileUtil extends FileUtilRt {
/**
* Converts given path to canonical representation by eliminating '.'s, traversing '..'s, and omitting duplicate separators.
* Please note that this method is symlink-unfriendly (i.e. result of "/path/to/link/../next" most probably will differ from
- * what {@link java.io.File#getCanonicalPath()} will return) - so use with care.
+ * what {@link java.io.File#getCanonicalPath()} will return) - so use with care.
+ *
+ * If the path may contain synlinks, use use {@link FileUtil#toCanonicalPath(String, boolean)}
*/
@Contract("null -> null")
public static String toCanonicalPath(@Nullable String path) {
return toCanonicalPath(path, File.separatorChar, true);
}
+ /**
+ * When relative ../ parts do not escape outside of symlinks, the links are not expanded.
+ * That is, in the best-case scenario the original non-expanded path is preserved.
+ *
+ * Otherwise, returns a fully resolved path using {@link java.io.File#getCanonicalPath()}.
+ *
+ * Consider the following case:
+ *
+ * root/
+ * dir1/
+ * link_to_dir1
+ * dir2/
+ *
+ * 'root/dir1/link_to_dir1/../dir2' should be resolved to 'root/dir2'
+ */
+ @Contract("null, _ -> null")
+ public static String toCanonicalPath(@Nullable String path, boolean resolveSymlinksIfNecessary) {
+ return toCanonicalPath(path, File.separatorChar, true, resolveSymlinksIfNecessary);
+ }
+
@Contract("null, _ -> null")
public static String toCanonicalPath(@Nullable String path, char separatorChar) {
return toCanonicalPath(path, separatorChar, true);
@@ -664,6 +686,14 @@ public class FileUtil extends FileUtilRt {
@Contract("null, _, _ -> null")
private static String toCanonicalPath(@Nullable String path, char separatorChar, boolean removeLastSlash) {
+ return toCanonicalPath(path, separatorChar, removeLastSlash, false);
+ }
+
+ @Contract("null, _, _, _ -> null")
+ private static String toCanonicalPath(@Nullable String path,
+ final char separatorChar,
+ final boolean removeLastSlash,
+ final boolean resolveSymlinksIfNecessary) {
if (path == null || path.isEmpty()) {
return path;
}
@@ -676,6 +706,21 @@ public class FileUtil extends FileUtilRt {
return path;
}
+ final String finalPath = path;
+ NotNullProducer realCanonicalPath = !resolveSymlinksIfNecessary ? null : new NotNullProducer() {
+ @NotNull
+ @Override
+ public String produce() {
+ try {
+ return new File(finalPath).getCanonicalPath().replace(separatorChar, '/');
+ }
+ catch (IOException ignore) {
+ // fall back to the default behavior
+ return toCanonicalPath(finalPath, separatorChar, removeLastSlash, false);
+ }
+ }
+ };
+
StringBuilder result = new StringBuilder(path.length());
int start = processRoot(path, result);
int dots = 0;
@@ -685,7 +730,9 @@ public class FileUtil extends FileUtilRt {
char c = path.charAt(i);
if (c == '/') {
if (!separator) {
- processDots(result, dots, start);
+ if (!processDots(result, dots, start, resolveSymlinksIfNecessary)) {
+ return realCanonicalPath.produce();
+ }
dots = 0;
}
separator = true;
@@ -710,7 +757,9 @@ public class FileUtil extends FileUtilRt {
}
if (dots > 0) {
- processDots(result, dots, start);
+ if (!processDots(result, dots, start, resolveSymlinksIfNecessary)) {
+ return realCanonicalPath.produce();
+ }
}
int lastChar = result.length() - 1;
@@ -754,7 +803,8 @@ public class FileUtil extends FileUtilRt {
return 0;
}
- private static void processDots(@NotNull StringBuilder result, int dots, int start) {
+ @Contract("_, _, _, false -> true")
+ private static boolean processDots(@NotNull StringBuilder result, int dots, int start, boolean resolveSymlinksIfNecessary) {
if (dots == 2) {
int pos = -1;
if (!StringUtil.endsWith(result, "/../") && !StringUtil.equals(result, "../")) {
@@ -770,6 +820,9 @@ public class FileUtil extends FileUtilRt {
}
}
if (pos >= 0) {
+ if (resolveSymlinksIfNecessary && FileSystemUtil.isSymLink(new File(result.toString()))) {
+ return false;
+ }
result.delete(pos, result.length());
}
else {
@@ -780,6 +833,7 @@ public class FileUtil extends FileUtilRt {
StringUtil.repeatSymbol(result, '.', dots);
result.append('/');
}
+ return true;
}
/**
diff --git a/platform/util/testSrc/com/intellij/openapi/util/io/FileUtilHeavyTest.java b/platform/util/testSrc/com/intellij/openapi/util/io/FileUtilHeavyTest.java
index bd6c32bfb57e..0e118cab81bb 100644
--- a/platform/util/testSrc/com/intellij/openapi/util/io/FileUtilHeavyTest.java
+++ b/platform/util/testSrc/com/intellij/openapi/util/io/FileUtilHeavyTest.java
@@ -227,6 +227,59 @@ public class FileUtilHeavyTest {
FileUtil.delete(linkDir);
assertEquals(1, targetDir.list().length);
}
+
+
+ @Test
+ public void testToCanonicalPathSymLinksAware() throws Exception {
+ assumeTrue(SystemInfo.areSymLinksSupported);
+
+ File root = IoTestUtil.createTestDir(myTempDirectory, "root");
+ assertTrue(new File(root, "dir1/dir2/dir3/dir4").mkdirs());
+
+ // non-recursive link
+ IoTestUtil.createSymLink(new File(root, "dir1/dir2").getPath(), new File(root, "dir1/dir2_link").getPath());
+ // recursive links to a parent dir
+ IoTestUtil.createSymLink(new File(root, "dir1").getPath(), new File(root, "dir1/dir1_link").getPath());
+
+ // I) links should NOT be resolved when ../ stays inside the linked path
+ // I.I) non-recursive links
+ assertEquals(root + "/dir1/dir2_link", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/./", true));
+ assertEquals(root + "/dir1/dir2_link", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/dir3/../", true));
+ assertEquals(root + "/dir1/dir2_link/dir3", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/dir3/dir4/../", true));
+ assertEquals(root + "/dir1/dir2_link", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/dir3/dir4/../../", true));
+ assertEquals(root + "/dir1/dir2_link", FileUtil.toCanonicalPath(root + "/dir1/../dir1/dir2_link/dir3/../", true));
+
+ // I.II) recursive links
+ assertEquals(root + "/dir1/dir1_link", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/./", true));
+ assertEquals(root + "/dir1/dir1_link", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/dir2/../", true));
+ assertEquals(root + "/dir1/dir1_link/dir2", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/dir2/dir3/../", true));
+ assertEquals(root + "/dir1/dir1_link", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/dir2/dir3/../../", true));
+ assertEquals(root + "/dir1/dir1_link", FileUtil.toCanonicalPath(root + "/dir1/../dir1/dir1_link/dir2/../", true));
+
+ // II) links should be resolved is ../ escapes outside
+
+ // II.I) non-recursive links
+ assertEquals(root + "/dir1", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/../", true));
+ assertEquals(root + "/dir1/dir2", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/../dir2", true));
+ assertEquals(root + "/dir1/dir2", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/../../dir1/dir2", true));
+ assertEquals(root + "/dir1/dir2", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/dir3/../../dir2", true));
+ assertEquals(root + "/dir1/dir2", FileUtil.toCanonicalPath(root + "/dir1/dir2_link/dir3/../../../dir1/dir2", true));
+ assertEquals(root + "/dir1/dir2", FileUtil.toCanonicalPath(root + "/dir1/../dir1/dir2_link/../dir2", true));
+
+ // II.I) recursive links
+ assertEquals(root.getPath(), FileUtil.toCanonicalPath(root + "/dir1/dir1_link/../", true));
+ assertEquals(root + "/dir1", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/../dir1", true));
+ assertEquals(root + "/dir1", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/../../root/dir1", true));
+ assertEquals(root + "/dir1", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/dir3/../../dir1", true));
+ assertEquals(root + "/dir1", FileUtil.toCanonicalPath(root + "/dir1/dir1_link/dir3/../../../root/dir1", true));
+ assertEquals(root + "/dir1", FileUtil.toCanonicalPath(root + "/dir1/../dir1/dir1_link/../dir1", true));
+
+ // some corner cases, behavior should be the same as the default FileUtil.toCanonicalPath
+ assertEquals(FileUtil.toCanonicalPath("..", false), FileUtil.toCanonicalPath("..", true));
+ assertEquals(FileUtil.toCanonicalPath("../", false), FileUtil.toCanonicalPath("../", true));
+ assertEquals(FileUtil.toCanonicalPath("/..", false), FileUtil.toCanonicalPath("/..", true));
+ assertEquals(FileUtil.toCanonicalPath("/../", false), FileUtil.toCanonicalPath("/../", true));
+ }
@Test
public void testCaseSensitivityDetection() throws IOException {