Windows, JNI: implement nativeGetLongPath

Implement a JNI method that can resolve 8dot3
style paths. This is necessary because we need to
be able to compare 8dot3 style paths and long
paths [1], and because implementing this correctly
in Java seems to be impossible [2].

This change also adds tests for the JNI isJunction
implementation.

See https://github.com/bazelbuild/bazel/issues/2101

[1] https://github.com/bazelbuild/bazel/issues/2145
[2] https://github.com/bazelbuild/bazel/issues/2145#issuecomment-266766716

--
PiperOrigin-RevId: 142123750
MOS_MIGRATED_REVID=142123750
diff --git a/src/main/java/com/google/devtools/build/lib/windows/WindowsFileOperations.java b/src/main/java/com/google/devtools/build/lib/windows/WindowsFileOperations.java
index 0eb287e..d95f902 100644
--- a/src/main/java/com/google/devtools/build/lib/windows/WindowsFileOperations.java
+++ b/src/main/java/com/google/devtools/build/lib/windows/WindowsFileOperations.java
@@ -30,6 +30,8 @@
 
   static native int nativeIsJunction(String path, String[] error);
 
+  static native boolean nativeGetLongPath(String path, String[] result, String[] error);
+
   /** Determines whether `path` is a junction point or directory symlink. */
   public static boolean isJunction(String path) throws IOException {
     WindowsJniLoader.loadJni();
@@ -44,7 +46,37 @@
     }
   }
 
+  /**
+   * Returns the long path associated with the input `path`.
+   *
+   * <p>This method resolves all 8dot3 style components of the path and returns the long format. For
+   * example, if the input is "C:/progra~1/micros~1" the result may be "C:\Program Files\Microsoft
+   * Visual Studio 14.0". The returned path is Windows-style in that it uses backslashes, even if
+   * the input uses forward slashes.
+   *
+   * <p>May return an UNC path if `path` or its resolution is sufficiently long.
+   *
+   * @throws IOException if the `path` is not found or some other I/O error occurs
+   */
+  public static String getLongPath(String path) throws IOException {
+    String[] result = new String[] {null};
+    String[] error = new String[] {null};
+    if (nativeGetLongPath(asLongPath(path), result, error)) {
+      return result[0];
+    } else {
+      throw new IOException(error[0]);
+    }
+  }
+
+  /**
+   * Returns a Windows-style path suitable to pass to Widechar Win32 API functions.
+   *
+   * <p>Returns an UNC path if `path` is longer than `MAX_PATH` (in <windows.h>). If it's shorter or
+   * is already an UNC path, then this method returns `path` itself.
+   */
   static String asLongPath(String path) {
-    return "\\\\?\\" + path.replace('/', '\\');
+    return path.length() > 260 && !path.startsWith("\\\\?\\")
+        ? ("\\\\?\\" + path.replace('/', '\\'))
+        : path;
   }
 }
diff --git a/src/main/native/windows_file_operations.cc b/src/main/native/windows_file_operations.cc
index 9f74293..137121a 100644
--- a/src/main/native/windows_file_operations.cc
+++ b/src/main/native/windows_file_operations.cc
@@ -15,13 +15,18 @@
 #include <jni.h>
 #include <windows.h>
 
-#include <memory>
 #include <string>
+#include <type_traits>  // static_assert
 
 #include "src/main/native/windows_util.h"
 
 namespace windows_util {
 
+// Ensure we can safely cast (const) jchar* to LP(C)WSTR.
+// This is true with MSVC but usually not with GCC.
+static_assert(sizeof(jchar) == sizeof(WCHAR),
+              "jchar and WCHAR should be the same size");
+
 // Keep in sync with j.c.g.devtools.build.lib.windows.WindowsFileOperations
 enum {
   IS_JUNCTION_YES = 0,
@@ -45,7 +50,7 @@
 //   created using "mklink" instead of "mklink /d", as such symlinks don't
 //   behave the same way as directories (e.g. they can't be listed)
 // - IS_JUNCTION_ERROR, if `path` doesn't exist or some error occurred
-static int IsJunctionOrDirectorySymlink(const wchar_t* path) {
+static int IsJunctionOrDirectorySymlink(LPCWSTR path) {
   DWORD attrs = GetFileAttributesW(path);
   if (attrs == INVALID_FILE_ATTRIBUTES) {
     return IS_JUNCTION_ERROR;
@@ -59,26 +64,59 @@
   }
 }
 
+static void MaybeReportLastError(string reason, JNIEnv* env,
+                                 jobjectArray error_msg_holder) {
+  if (error_msg_holder != nullptr &&
+      env->GetArrayLength(error_msg_holder) > 0) {
+    std::string error_str = windows_util::GetLastErrorString(reason);
+    jstring error_msg = env->NewStringUTF(error_str.c_str());
+    env->SetObjectArrayElement(error_msg_holder, 0, error_msg);
+  }
+}
+
 }  // namespace windows_util
 
 extern "C" JNIEXPORT jint JNICALL
 Java_com_google_devtools_build_lib_windows_WindowsFileOperations_nativeIsJunction(
     JNIEnv* env, jclass clazz, jstring path, jobjectArray error_msg_holder) {
   bool report_error =
-      error_msg_holder != NULL && env->GetArrayLength(error_msg_holder) > 0;
-  std::unique_ptr<wchar_t[]> long_path(
-      windows_util::JstringToWstring(env, path));
-  int result = windows_util::IsJunctionOrDirectorySymlink(long_path.get());
+      error_msg_holder != nullptr && env->GetArrayLength(error_msg_holder) > 0;
+  const jchar* wpath = env->GetStringChars(path, nullptr);
+  int result = windows_util::IsJunctionOrDirectorySymlink((LPCWSTR)wpath);
+  env->ReleaseStringChars(path, wpath);
   if (result == windows_util::IS_JUNCTION_ERROR && report_error) {
-    // Getting the string's characters again in UTF8 encoding is probably
-    // easier than converting `long_path` using `wcstombs(3)`.
-    const char* path_cstr = env->GetStringUTFChars(path, NULL);
-    std::string error_str = windows_util::GetLastErrorString(
-        std::string("GetFileAttributes(") + std::string(path_cstr) +
-        std::string(")"));
+    // Getting the string's characters again in UTF8 encoding is
+    // easier than converting `wpath` using `wcstombs(3)`.
+    const char* path_cstr = env->GetStringUTFChars(path, nullptr);
+    windows_util::MaybeReportLastError(std::string("GetFileAttributes(") +
+                                           std::string(path_cstr) +
+                                           std::string(")"),
+                                       env, error_msg_holder);
     env->ReleaseStringUTFChars(path, path_cstr);
-    jstring error_msg = env->NewStringUTF(error_str.c_str());
-    env->SetObjectArrayElement(error_msg_holder, 0, error_msg);
   }
   return result;
 }
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_google_devtools_build_lib_windows_WindowsFileOperations_nativeGetLongPath(
+    JNIEnv* env, jclass clazz, jstring path, jobjectArray result_holder,
+    jobjectArray error_msg_holder) {
+  const jchar* cpath = nullptr;
+  cpath = env->GetStringChars(path, nullptr);
+  jchar result[0x8010] = {0};  // 32K max size + UNC prefix + some safety buffer
+  DWORD len = GetLongPathNameW((LPCWSTR)cpath, (LPWSTR)result, 0x8010);
+  env->ReleaseStringChars(path, cpath);
+  if (len > 0 && len < 0x8010) {
+    env->SetObjectArrayElement(result_holder, 0,
+                               env->NewString((const jchar*)result, len));
+    return JNI_TRUE;
+  } else {
+    const char* path_cstr = env->GetStringUTFChars(path, nullptr);
+    windows_util::MaybeReportLastError(std::string("GetLongPathNameW(") +
+                                           std::string(path_cstr) +
+                                           std::string(")"),
+                                       env, error_msg_holder);
+    env->ReleaseStringUTFChars(path, path_cstr);
+    return JNI_FALSE;
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/vfs/WindowsFileSystemTest.java b/src/test/java/com/google/devtools/build/lib/vfs/WindowsFileSystemTest.java
index e0b62f1..8f25ff8 100644
--- a/src/test/java/com/google/devtools/build/lib/vfs/WindowsFileSystemTest.java
+++ b/src/test/java/com/google/devtools/build/lib/vfs/WindowsFileSystemTest.java
@@ -200,4 +200,23 @@
                 linkPath.toPath(), WindowsFileSystem.symlinkOpts(/* followSymlinks */ true)))
         .isFalse();
   }
+
+  @Test
+  public void testIsJunctionHandlesFilesystemChangesCorrectly() throws Exception {
+    File longPath =
+        testUtil.scratchFile("target\\helloworld.txt", "hello").toAbsolutePath().toFile();
+    File shortPath = new File(longPath.getParentFile(), "hellow~1.txt");
+    assertThat(WindowsFileSystem.isJunction(longPath)).isFalse();
+    assertThat(WindowsFileSystem.isJunction(shortPath)).isFalse();
+
+    assertThat(longPath.delete()).isTrue();
+    testUtil.createJunctions(ImmutableMap.of("target\\helloworld.txt", "target"));
+    assertThat(WindowsFileSystem.isJunction(longPath)).isTrue();
+    assertThat(WindowsFileSystem.isJunction(shortPath)).isTrue();
+
+    assertThat(longPath.delete()).isTrue();
+    assertThat(longPath.mkdir()).isTrue();
+    assertThat(WindowsFileSystem.isJunction(longPath)).isFalse();
+    assertThat(WindowsFileSystem.isJunction(shortPath)).isFalse();
+  }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/windows/WindowsFileOperationsTest.java b/src/test/java/com/google/devtools/build/lib/windows/WindowsFileOperationsTest.java
index 84f7752..773daee 100644
--- a/src/test/java/com/google/devtools/build/lib/windows/WindowsFileOperationsTest.java
+++ b/src/test/java/com/google/devtools/build/lib/windows/WindowsFileOperationsTest.java
@@ -159,4 +159,99 @@
                 linkPath.toPath(), WindowsFileSystem.symlinkOpts(/* followSymlinks */ true)))
         .isFalse();
   }
+
+  @Test
+  public void testIsJunctionHandlesFilesystemChangesCorrectly() throws Exception {
+    File helloFile =
+        testUtil.scratchFile("target\\helloworld.txt", "hello").toAbsolutePath().toFile();
+
+    // Assert that a file is identified as not a junction.
+    String longPath = helloFile.getAbsolutePath();
+    String shortPath = new File(helloFile.getParentFile(), "hellow~1.txt").getAbsolutePath();
+    assertThat(WindowsFileOperations.isJunction(longPath)).isFalse();
+    assertThat(WindowsFileOperations.isJunction(shortPath)).isFalse();
+
+    // Assert that after deleting the file and creating a junction with the same path, it is
+    // identified as a junction.
+    assertThat(helloFile.delete()).isTrue();
+    testUtil.createJunctions(ImmutableMap.of("target\\helloworld.txt", "target"));
+    assertThat(WindowsFileOperations.isJunction(longPath)).isTrue();
+    assertThat(WindowsFileOperations.isJunction(shortPath)).isTrue();
+
+    // Assert that after deleting the file and creating a directory with the same path, it is
+    // identified as not a junction.
+    assertThat(helloFile.delete()).isTrue();
+    assertThat(helloFile.mkdir()).isTrue();
+    assertThat(WindowsFileOperations.isJunction(longPath)).isFalse();
+    assertThat(WindowsFileOperations.isJunction(shortPath)).isFalse();
+  }
+
+  @Test
+  public void testGetLongPath() throws Exception {
+    File foo = testUtil.scratchDir("foo").toAbsolutePath().toFile();
+    assertThat(foo.exists()).isTrue();
+    assertThat(WindowsFileOperations.getLongPath(foo.getAbsolutePath())).endsWith("foo");
+
+    String longPath = foo.getAbsolutePath() + "\\will.exist\\helloworld.txt";
+    String shortPath = foo.getAbsolutePath() + "\\will~1.exi\\hellow~1.txt";
+
+    // Assert that the long path resolution fails for non-existent file.
+    try {
+      WindowsFileOperations.getLongPath(longPath);
+      fail("expected to throw");
+    } catch (IOException e) {
+      assertThat(e.getMessage()).contains("GetLongPathName");
+    }
+    try {
+      WindowsFileOperations.getLongPath(shortPath);
+      fail("expected to throw");
+    } catch (IOException e) {
+      assertThat(e.getMessage()).contains("GetLongPathName");
+    }
+
+    // Create the file, assert that long path resolution works and is correct.
+    File helloFile =
+        testUtil.scratchFile("foo/will.exist/helloworld.txt", "hello").toAbsolutePath().toFile();
+    assertThat(helloFile.getAbsolutePath()).isEqualTo(longPath);
+    assertThat(helloFile.exists()).isTrue();
+    assertThat(new File(longPath).exists()).isTrue();
+    assertThat(new File(shortPath).exists()).isTrue();
+    assertThat(WindowsFileOperations.getLongPath(longPath)).endsWith("will.exist\\helloworld.txt");
+    assertThat(WindowsFileOperations.getLongPath(shortPath)).endsWith("will.exist\\helloworld.txt");
+
+    // Delete the file and the directory, assert that long path resolution fails for them.
+    assertThat(helloFile.delete()).isTrue();
+    assertThat(helloFile.getParentFile().delete()).isTrue();
+    try {
+      WindowsFileOperations.getLongPath(longPath);
+      fail("expected to throw");
+    } catch (IOException e) {
+      assertThat(e.getMessage()).contains("GetLongPathName");
+    }
+    try {
+      WindowsFileOperations.getLongPath(shortPath);
+      fail("expected to throw");
+    } catch (IOException e) {
+      assertThat(e.getMessage()).contains("GetLongPathName");
+    }
+
+    // Create the directory and file with different names, but same 8dot3 names, assert that the
+    // resolution is still correct.
+    helloFile =
+        testUtil
+            .scratchFile("foo/will.exist_again/hellowelt.txt", "hello")
+            .toAbsolutePath()
+            .toFile();
+    assertThat(new File(shortPath).exists()).isTrue();
+    assertThat(WindowsFileOperations.getLongPath(shortPath))
+        .endsWith("will.exist_again\\hellowelt.txt");
+    assertThat(WindowsFileOperations.getLongPath(foo + "\\will.exist_again\\hellowelt.txt"))
+        .endsWith("will.exist_again\\hellowelt.txt");
+    try {
+      WindowsFileOperations.getLongPath(longPath);
+      fail("expected to throw");
+    } catch (IOException e) {
+      assertThat(e.getMessage()).contains("GetLongPathName");
+    }
+  }
 }