Bug 1555319: Normalize the case of the installation path to always get a consistent install hash. r=froydnj, a=RyanVM
authorDave Townsend <dtownsend@oxymoronical.com>
Wed, 12 Jun 2019 10:48:09 -0700
changeset 522860 e09145bef79d7faa1ab3d35d2a2401bf90064a79
parent 522859 e7ed9a2f00259062284fdb3ebeb4394f7a57dae7
child 522861 a3ca22e1ef37d4d0e9d20c4a056bc75de2686f5e
push id64
push userryanvm@gmail.com
push dateThu, 11 Jul 2019 21:28:07 +0000
treeherdermozilla-esr68@a1d610b7cb6f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersfroydnj, RyanVM
bugs1555319
milestone68.1.0
Bug 1555319: Normalize the case of the installation path to always get a consistent install hash. r=froydnj, a=RyanVM The XRE_EXECUTABLE_FILE directory entry gives us the actual path that the binary was launched with. On systems where the filesystem is case insensitive this can be in any case, which ends up being a different install hash. This patch ensures that we get the correct case for the install path before generating the hash. We have the problem of users who are already affected by this issue. This patch also leaves the old hash available, if no default profile is found for the correct hash then we also check for a profile for the old hash, if so we use it for this hash going forwards. Testing this is kind of a pain, we have to add a way to override the old hash that we will check against. I'm not totally happy with how it is done here but not sure there is anything better. This also adds a test that calling xpcshell with differing cases returns the same install hash. Differential Revision: https://phabricator.services.mozilla.com/D34774
toolkit/profile/nsIToolkitProfileService.idl
toolkit/profile/nsToolkitProfileService.cpp
toolkit/profile/nsToolkitProfileService.h
toolkit/profile/xpcshell/head.js
toolkit/profile/xpcshell/test_fix_directory_case.js
toolkit/profile/xpcshell/test_ignore_legacy_directory.js
toolkit/profile/xpcshell/xpcshell.ini
toolkit/xre/nsAppRunner.cpp
toolkit/xre/nsXREDirProvider.cpp
toolkit/xre/nsXREDirProvider.h
toolkit/xre/test/show_hash.js
toolkit/xre/test/test_install_hash.js
toolkit/xre/test/xpcshell.ini
--- a/toolkit/profile/nsIToolkitProfileService.idl
+++ b/toolkit/profile/nsIToolkitProfileService.idl
@@ -82,16 +82,17 @@ interface nsIToolkitProfileService : nsI
      * aRootDir and aLocalDir are set to the data and local directories for the
      * profile data. If a profile from the database was selected it will be
      * returned in aProfile.
      * This returns true if a new profile was created.
      * This method is primarily for testing. It can be called only once.
      */
     bool selectStartupProfile(in Array<ACString> aArgv,
                               in boolean aIsResetting, in AUTF8String aUpdateChannel,
+                              in AUTF8String aLegacyInstallHash,
                               out nsIFile aRootDir, out nsIFile aLocalDir,
                               out nsIToolkitProfile aProfile);
 
     /**
      * Get a profile by name. This is mainly for use by the -P
      * commandline flag.
      *
      * @param aName The profile name to find.
--- a/toolkit/profile/nsToolkitProfileService.cpp
+++ b/toolkit/profile/nsToolkitProfileService.cpp
@@ -766,19 +766,32 @@ nsresult nsToolkitProfileService::Init()
     rv = gDirServiceProvider->GetInstallHash(installHash);
     NS_ENSURE_SUCCESS(rv, rv);
     CopyUTF16toUTF8(installHash, mInstallSection);
     mInstallSection.Insert(INSTALL_PREFIX, 0);
 
     // Try to find the descriptor for the default profile for this install.
     rv = mProfileDB.GetString(mInstallSection.get(), "Default",
                               installProfilePath);
+
     // Not having a value means this install doesn't appear in installs.ini so
     // this is the first run for this install.
-    mIsFirstRun = NS_FAILED(rv);
+    if (NS_FAILED(rv)) {
+      mIsFirstRun = true;
+
+      // Gets the install section that would have been created if the install
+      // path has incorrect casing (see bug 1555319). We use this later during
+      // profile selection.
+      rv = gDirServiceProvider->GetLegacyInstallHash(installHash);
+      NS_ENSURE_SUCCESS(rv, rv);
+      CopyUTF16toUTF8(installHash, mLegacyInstallSection);
+      mLegacyInstallSection.Insert(INSTALL_PREFIX, 0);
+    } else {
+      mIsFirstRun = false;
+    }
   }
 
   nsToolkitProfile* currentProfile = nullptr;
 
 #ifdef MOZ_DEV_EDITION
   nsCOMPtr<nsIFile> ignoreDevEditionProfile;
   rv = mAppData->Clone(getter_AddRefs(ignoreDevEditionProfile));
   if (NS_FAILED(rv)) {
@@ -1093,33 +1106,38 @@ nsresult nsToolkitProfileService::Create
 
 /**
  * An implementation of SelectStartupProfile callable from JavaScript via XPCOM.
  * See nsIToolkitProfileService.idl.
  */
 NS_IMETHODIMP
 nsToolkitProfileService::SelectStartupProfile(
     const nsTArray<nsCString>& aArgv, bool aIsResetting,
-    const nsACString& aUpdateChannel, nsIFile** aRootDir, nsIFile** aLocalDir,
-    nsIToolkitProfile** aProfile, bool* aDidCreate) {
+    const nsACString& aUpdateChannel, const nsACString& aLegacyInstallHash,
+    nsIFile** aRootDir, nsIFile** aLocalDir, nsIToolkitProfile** aProfile,
+    bool* aDidCreate) {
   int argc = aArgv.Length();
   // Our command line handling expects argv to be null-terminated so construct
   // an appropriate array.
   auto argv = MakeUnique<char*[]>(argc + 1);
   // Also, our command line handling removes things from the array without
   // freeing them so keep track of what we've created separately.
   auto allocated = MakeUnique<UniqueFreePtr<char>[]>(argc);
 
   for (int i = 0; i < argc; i++) {
     allocated[i].reset(ToNewCString(aArgv[i]));
     argv[i] = allocated[i].get();
   }
   argv[argc] = nullptr;
 
   mUpdateChannel = aUpdateChannel;
+  if (!aLegacyInstallHash.IsEmpty()) {
+    mLegacyInstallSection.Assign(aLegacyInstallHash);
+    mLegacyInstallSection.Insert(INSTALL_PREFIX, 0);
+  }
 
   bool wasDefault;
   nsresult rv =
       SelectStartupProfile(&argc, argv.get(), aIsResetting, aRootDir, aLocalDir,
                            aProfile, aDidCreate, &wasDefault);
 
   // Since we were called outside of the normal startup path complete any
   // startup tasks.
@@ -1358,16 +1376,59 @@ nsresult nsToolkitProfileService::Select
                "Error: argument --profilemanager is invalid when argument "
                "--osint is specified\n");
     return NS_ERROR_FAILURE;
   }
   if (ar == ARG_FOUND) {
     return NS_ERROR_SHOW_PROFILE_MANAGER;
   }
 
+  if (mIsFirstRun && mUseDedicatedProfile &&
+      !mInstallSection.Equals(mLegacyInstallSection)) {
+    // The default profile could be assigned to a hash generated from an
+    // incorrectly cased version of the installation directory (see bug
+    // 1555319). Ideally we'd do all this while loading profiles.ini but we
+    // can't override the legacy section value before that for tests.
+    nsCString defaultDescriptor;
+    rv = mProfileDB.GetString(mLegacyInstallSection.get(), "Default",
+                              defaultDescriptor);
+
+    if (NS_SUCCEEDED(rv)) {
+      // There is a default here, need to see if it matches any profiles.
+      bool isRelative;
+      nsCString descriptor;
+
+      for (RefPtr<nsToolkitProfile> profile : mProfiles) {
+        GetProfileDescriptor(profile, descriptor, &isRelative);
+
+        if (descriptor.Equals(defaultDescriptor)) {
+          // Found the default profile. Copy the install section over to
+          // the correct location. We leave the old info in place for older
+          // versions of Firefox to use.
+          nsTArray<UniquePtr<KeyValue>> strings =
+              GetSectionStrings(&mProfileDB, mLegacyInstallSection.get());
+          for (const auto& kv : strings) {
+            mProfileDB.SetString(mInstallSection.get(), kv->key.get(),
+                                 kv->value.get());
+          }
+
+          // Flush now. This causes a small blip in startup but it should be
+          // one time only whereas not flushing means we have to do this search
+          // on every startup.
+          Flush();
+
+          // Now start up with the found profile.
+          mDedicatedProfile = profile;
+          mIsFirstRun = false;
+          break;
+        }
+      }
+    }
+  }
+
   // If this is a first run then create a new profile.
   if (mIsFirstRun) {
     // If we're configured to always show the profile manager then don't create
     // a new profile to use.
     if (!mStartWithLast) {
       return NS_ERROR_SHOW_PROFILE_MANAGER;
     }
 
--- a/toolkit/profile/nsToolkitProfileService.h
+++ b/toolkit/profile/nsToolkitProfileService.h
@@ -133,16 +133,20 @@ class nsToolkitProfileService final : pu
   // The location of profiles.ini.
   nsCOMPtr<nsIFile> mProfileDBFile;
   // The location of installs.ini.
   nsCOMPtr<nsIFile> mInstallDBFile;
   // The data loaded from profiles.ini.
   nsINIParser mProfileDB;
   // The section in the profiles db for the current install.
   nsCString mInstallSection;
+  // A legacy install section which may have been generated against an
+  // installation directory with an incorrect case (see bug 1555319). It is only
+  // really held here so that it can be overridden by tests.
+  nsCString mLegacyInstallSection;
   // Whether to start with the selected profile by default.
   bool mStartWithLast;
   // True if during startup it appeared that this is the first run.
   bool mIsFirstRun;
   // True if the default profile is the separate dev-edition-profile.
   bool mUseDevEditionProfile;
   // True if this install should use a dedicated default profile.
   const bool mUseDedicatedProfile;
--- a/toolkit/profile/xpcshell/head.js
+++ b/toolkit/profile/xpcshell/head.js
@@ -93,24 +93,24 @@ function makeRandomProfileDir(name) {
   file.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
   return file;
 }
 
 /**
  * A wrapper around nsIToolkitProfileService.selectStartupProfile to make it
  * a bit nicer to use from JS.
  */
-function selectStartupProfile(args = [], isResetting = false) {
+function selectStartupProfile(args = [], isResetting = false, legacyHash = "") {
   let service = getProfileService();
   let rootDir = {};
   let localDir = {};
   let profile = {};
   let didCreate = service.selectStartupProfile(["xpcshell", ...args], isResetting,
-                                               UPDATE_CHANNEL, rootDir, localDir,
-                                               profile);
+                                               UPDATE_CHANNEL, legacyHash, rootDir,
+                                               localDir, profile);
 
   if (profile.value) {
     Assert.ok(rootDir.value.equals(profile.value.rootDir), "Should have matched the root dir.");
     Assert.ok(localDir.value.equals(profile.value.localDir), "Should have matched the local dir.");
     Assert.ok(service.currentProfile === profile.value, "Should have marked the profile as the current profile.");
   } else {
     Assert.ok(!service.currentProfile, "Should be no current profile.");
   }
new file mode 100644
--- /dev/null
+++ b/toolkit/profile/xpcshell/test_fix_directory_case.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the case where the user has an default profile set for the legacy
+ * install hash. This should be switched to the new hash and correctly used as
+ * the default.
+ */
+
+add_task(async () => {
+  let currentHash = xreDirProvider.getInstallHash();
+  let legacyHash = "F87E39E944FE466E";
+
+  let defaultProfile = makeRandomProfileDir("default");
+  let dedicatedProfile = makeRandomProfileDir("dedicated");
+  let devProfile = makeRandomProfileDir("devedition");
+
+  // Make sure we don't steal the old-style default.
+  writeCompatibilityIni(defaultProfile);
+
+  writeProfilesIni({
+    profiles: [{
+      name: "default",
+      path: defaultProfile.leafName,
+      default: true,
+    }, {
+      name: "dedicated",
+      path: dedicatedProfile.leafName,
+    }, {
+      name: "dev-edition-default",
+      path: devProfile.leafName,
+    }],
+    installs: {
+      [legacyHash]: {
+        default: dedicatedProfile.leafName,
+      },
+      "otherhash": {
+        default: "foobar",
+      },
+    },
+  });
+
+  let { profile: selectedProfile, didCreate } = selectStartupProfile([], false, legacyHash);
+  checkStartupReason("default");
+
+  let profileData = readProfilesIni();
+
+  Assert.ok(profileData.options.startWithLastProfile, "Should be set to start with the last profile.");
+  Assert.equal(profileData.profiles.length, 3, "Should have the right number of profiles.");
+
+  let profile = profileData.profiles[0];
+  Assert.equal(profile.name, `dedicated`, "Should have the right name.");
+  Assert.equal(profile.path, dedicatedProfile.leafName, "Should be the expected dedicated profile.");
+  Assert.ok(!profile.default, "Should not be marked as the old-style default.");
+
+  profile = profileData.profiles[1];
+  Assert.equal(profile.name, "default", "Should have the right name.");
+  Assert.equal(profile.path, defaultProfile.leafName, "Should be the original default profile.");
+  Assert.ok(profile.default, "Should be marked as the old-style default.");
+
+  Assert.equal(Object.keys(profileData.installs).length, 3, "Should be three known installs.");
+  Assert.equal(profileData.installs[currentHash].default, dedicatedProfile.leafName, "Should have switched to the new install hash.");
+  Assert.equal(profileData.installs[legacyHash].default, dedicatedProfile.leafName, "Should have kept the old install hash.");
+  Assert.equal(profileData.installs.otherhash.default, "foobar", "Should have kept the default for the other install.");
+
+  checkProfileService(profileData);
+
+  Assert.ok(!didCreate, "Should not have created a new profile.");
+  Assert.ok(selectedProfile.rootDir.equals(dedicatedProfile), "Should be using the right directory.");
+  Assert.equal(selectedProfile.name, "dedicated");
+});
new file mode 100644
--- /dev/null
+++ b/toolkit/profile/xpcshell/test_ignore_legacy_directory.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests the case where the user has an default profile set for both the legacy
+ * and new install hash. This should just use the default for the new install
+ * hash.
+ */
+
+add_task(async () => {
+  let currentHash = xreDirProvider.getInstallHash();
+  let legacyHash = "F87E39E944FE466E";
+
+  let defaultProfile = makeRandomProfileDir("default");
+  let dedicatedProfile = makeRandomProfileDir("dedicated");
+  let devProfile = makeRandomProfileDir("devedition");
+
+  // Make sure we don't steal the old-style default.
+  writeCompatibilityIni(defaultProfile);
+
+  writeProfilesIni({
+    profiles: [{
+      name: "default",
+      path: defaultProfile.leafName,
+      default: true,
+    }, {
+      name: "dedicated",
+      path: dedicatedProfile.leafName,
+    }, {
+      name: "dev-edition-default",
+      path: devProfile.leafName,
+    }],
+    installs: {
+      [legacyHash]: {
+        default: defaultProfile.leafName,
+      },
+      [currentHash]: {
+        default: dedicatedProfile.leafName,
+      },
+      "otherhash": {
+        default: "foobar",
+      },
+    },
+  });
+
+  let { profile: selectedProfile, didCreate } = selectStartupProfile([], false, legacyHash);
+  checkStartupReason("default");
+
+  let profileData = readProfilesIni();
+
+  Assert.ok(profileData.options.startWithLastProfile, "Should be set to start with the last profile.");
+  Assert.equal(profileData.profiles.length, 3, "Should have the right number of profiles.");
+
+  let profile = profileData.profiles[0];
+  Assert.equal(profile.name, `dedicated`, "Should have the right name.");
+  Assert.equal(profile.path, dedicatedProfile.leafName, "Should be the expected dedicated profile.");
+  Assert.ok(!profile.default, "Should not be marked as the old-style default.");
+
+  profile = profileData.profiles[1];
+  Assert.equal(profile.name, "default", "Should have the right name.");
+  Assert.equal(profile.path, defaultProfile.leafName, "Should be the original default profile.");
+  Assert.ok(profile.default, "Should be marked as the old-style default.");
+
+  profile = profileData.profiles[2];
+  Assert.equal(profile.name, "dev-edition-default", "Should have the right name.");
+  Assert.equal(profile.path, devProfile.leafName, "Should not be the original default profile.");
+  Assert.ok(!profile.default, "Should not be marked as the old-style default.");
+
+  Assert.equal(Object.keys(profileData.installs).length, 3, "Should be three known installs.");
+  Assert.equal(profileData.installs[currentHash].default, dedicatedProfile.leafName, "Should have switched to the new install hash.");
+  Assert.equal(profileData.installs[legacyHash].default, defaultProfile.leafName, "Should have ignored old install hash.");
+  Assert.equal(profileData.installs.otherhash.default, "foobar", "Should have kept the default for the other install.");
+
+  checkProfileService(profileData);
+
+  Assert.ok(!didCreate, "Should not have created a new profile.");
+  Assert.ok(selectedProfile.rootDir.equals(dedicatedProfile), "Should be using the right directory.");
+  Assert.equal(selectedProfile.name, "dedicated");
+});
--- a/toolkit/profile/xpcshell/xpcshell.ini
+++ b/toolkit/profile/xpcshell/xpcshell.ini
@@ -35,8 +35,10 @@ skip-if = devedition
 [test_check_backup.js]
 [test_missing_profilesini.js]
 [test_remove.js]
 [test_conflict_profiles.js]
 [test_conflict_installs.js]
 [test_invalid_descriptor.js]
 [test_legacy_empty.js]
 [test_legacy_select.js]
+[test_fix_directory_case.js]
+[test_ignore_legacy_directory.js]
--- a/toolkit/xre/nsAppRunner.cpp
+++ b/toolkit/xre/nsAppRunner.cpp
@@ -4710,22 +4710,18 @@ int XREMain::XRE_main(int argc, char* ar
   nsCOMPtr<nsIFile> binFile;
   rv = XRE_GetBinaryPath(getter_AddRefs(binFile));
   NS_ENSURE_SUCCESS(rv, 1);
 
   rv = binFile->GetPath(gAbsoluteArgv0Path);
   NS_ENSURE_SUCCESS(rv, 1);
 
   if (!mAppData->xreDirectory) {
-    nsCOMPtr<nsIFile> lf;
-    rv = XRE_GetBinaryPath(getter_AddRefs(lf));
-    if (NS_FAILED(rv)) return 2;
-
     nsCOMPtr<nsIFile> greDir;
-    rv = lf->GetParent(getter_AddRefs(greDir));
+    rv = binFile->GetParent(getter_AddRefs(greDir));
     if (NS_FAILED(rv)) return 2;
 
 #ifdef XP_MACOSX
     nsCOMPtr<nsIFile> parent;
     greDir->GetParent(getter_AddRefs(parent));
     greDir = parent.forget();
     greDir->AppendNative(NS_LITERAL_CSTRING("Resources"));
 #endif
--- a/toolkit/xre/nsXREDirProvider.cpp
+++ b/toolkit/xre/nsXREDirProvider.cpp
@@ -1170,46 +1170,97 @@ static nsresult GetRegWindowsAppDataFold
     // It was already null terminated.
     _retval.Truncate(resultLen - 1);
   }
 
   return NS_OK;
 }
 #endif
 
+static nsresult HashInstallPath(nsAString& aInstallPath, nsAString& aPathHash) {
+  const char* vendor = GetAppVendor();
+  if (vendor && vendor[0] == '\0') {
+    vendor = nullptr;
+  }
+
+  mozilla::UniquePtr<NS_tchar[]> hash;
+  nsresult rv =
+      ::GetInstallHash(PromiseFlatString(aInstallPath).get(), vendor, hash);
+  NS_ENSURE_SUCCESS(rv, rv);
+
+  // The hash string is a NS_tchar*, which is wchar* in Windows and char*
+  // elsewhere.
+#ifdef XP_WIN
+  aPathHash.Assign(hash.get());
+#else
+  aPathHash.AssignASCII(hash.get());
+#endif
+  return NS_OK;
+}
+
+/**
+ * Gets a hash of the installation directory.
+ */
 nsresult nsXREDirProvider::GetInstallHash(nsAString& aPathHash) {
   nsCOMPtr<nsIFile> installDir;
   nsCOMPtr<nsIFile> appFile;
   bool per = false;
   nsresult rv = GetFile(XRE_EXECUTABLE_FILE, &per, getter_AddRefs(appFile));
   NS_ENSURE_SUCCESS(rv, rv);
   rv = appFile->GetParent(getter_AddRefs(installDir));
   NS_ENSURE_SUCCESS(rv, rv);
 
+  // It is possible that the path we have is on a case insensitive
+  // filesystem in which case the path may vary depending on how the
+  // application is called. We want to normalize the case somehow.
+#ifdef XP_WIN
+  // Windows provides a way to get the correct case.
+  if (!mozilla::widget::WinUtils::ResolveJunctionPointsAndSymLinks(
+          installDir)) {
+    NS_WARNING("Failed to resolve install directory.");
+  }
+#elif defined(MOZ_WIDGET_COCOA)
+  // On OSX roundtripping through an FSRef fixes the case.
+  FSRef ref;
+  nsCOMPtr<nsILocalFileMac> macFile = do_QueryInterface(installDir);
+  rv = macFile->GetFSRef(&ref);
+  NS_ENSURE_SUCCESS(rv, rv);
+  rv = NS_NewLocalFileWithFSRef(&ref, true, getter_AddRefs(macFile));
+  NS_ENSURE_SUCCESS(rv, rv);
+  installDir = static_cast<nsIFile*>(macFile);
+#endif
+  // On linux XRE_EXECUTABLE_FILE already seems to be set to the correct path.
+
+  nsAutoString installPath;
+  rv = installDir->GetPath(installPath);
+  NS_ENSURE_SUCCESS(rv, rv);
+
+  return HashInstallPath(installPath, aPathHash);
+}
+
+/**
+ * Before bug 1555319 the directory hashed can have had an incorrect case.
+ * Access to that hash is still available through this function. It is needed so
+ * we can migrate users who may have an incorrect hash in profiles.ini. This
+ * support can probably be removed in a few releases time.
+ */
+nsresult nsXREDirProvider::GetLegacyInstallHash(nsAString& aPathHash) {
+  nsCOMPtr<nsIFile> installDir;
+  nsCOMPtr<nsIFile> appFile;
+  bool per = false;
+  nsresult rv = GetFile(XRE_EXECUTABLE_FILE, &per, getter_AddRefs(appFile));
+  NS_ENSURE_SUCCESS(rv, rv);
+  rv = appFile->GetParent(getter_AddRefs(installDir));
+  NS_ENSURE_SUCCESS(rv, rv);
+
   nsAutoString installPath;
   rv = installDir->GetPath(installPath);
   NS_ENSURE_SUCCESS(rv, rv);
 
-  const char* vendor = GetAppVendor();
-  if (vendor && vendor[0] == '\0') {
-    vendor = nullptr;
-  }
-
-  mozilla::UniquePtr<NS_tchar[]> hash;
-  rv = ::GetInstallHash(PromiseFlatString(installPath).get(), vendor, hash);
-  NS_ENSURE_SUCCESS(rv, rv);
-
-  // The hash string is a NS_tchar*, which is wchar* in Windows and char*
-  // elsewhere.
-#ifdef XP_WIN
-  aPathHash.Assign(hash.get());
-#else
-  aPathHash.AssignASCII(hash.get());
-#endif
-  return NS_OK;
+  return HashInstallPath(installPath, aPathHash);
 }
 
 nsresult nsXREDirProvider::GetUpdateRootDir(nsIFile** aResult,
                                             bool aGetOldLocation) {
 #ifndef XP_WIN
   // There is no old update location on platforms other than Windows. Windows is
   // the only platform for which we migrated the update directory.
   if (aGetOldLocation) {
--- a/toolkit/xre/nsXREDirProvider.h
+++ b/toolkit/xre/nsXREDirProvider.h
@@ -45,16 +45,18 @@ class nsXREDirProvider final : public ns
                       nsIDirectoryServiceProvider* aAppProvider = nullptr);
   ~nsXREDirProvider();
 
   static already_AddRefed<nsXREDirProvider> GetSingleton();
 
   nsresult GetUserProfilesRootDir(nsIFile** aResult);
   nsresult GetUserProfilesLocalDir(nsIFile** aResult);
 
+  nsresult GetLegacyInstallHash(nsAString& aPathHash);
+
   // We only set the profile dir, we don't ensure that it exists;
   // that is the responsibility of the toolkit profile service.
   // We also don't fire profile-changed notifications... that is
   // the responsibility of the apprunner.
   nsresult SetProfile(nsIFile* aProfileDir, nsIFile* aProfileLocalDir);
 
   void InitializeUserPrefs();
 
new file mode 100644
--- /dev/null
+++ b/toolkit/xre/test/show_hash.js
@@ -0,0 +1,2 @@
+const xre = Cc["@mozilla.org/xre/directory-provider;1"].getService(Ci.nsIXREDirProvider);
+dump(`${xre.getInstallHash(false)}\n`);
new file mode 100644
--- /dev/null
+++ b/toolkit/xre/test/test_install_hash.js
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This runs the xpcshell binary with different cases for the executable path.
+ * They should all result in the same installation hash.
+ */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { Subprocess } = ChromeUtils.import("resource://gre/modules/Subprocess.jsm");
+
+const XRE = Cc["@mozilla.org/xre/directory-provider;1"].getService(Ci.nsIXREDirProvider);
+const HASH = XRE.getInstallHash(false);
+const EXE = Services.dirsvc.get("XREExeF", Ci.nsIFile);
+const SCRIPT = do_get_file("show_hash.js", false);
+
+async function getHash(bin) {
+  try {
+    let proc = await Subprocess.call({
+      command: bin.path,
+      arguments: [SCRIPT.path],
+    });
+
+    let result = "";
+    let string;
+    while ((string = await proc.stdout.readString())) {
+      result += string;
+    }
+
+    return result.trim();
+  } catch (e) {
+    if (e.errorCode == Subprocess.ERROR_BAD_EXECUTABLE) {
+      return null;
+    }
+    throw e;
+  }
+}
+
+// Walks through a path's entries and calls a mutator function to change the
+// case of each.
+function mutatePath(path, mutator) {
+  let parts = [];
+  let index = 0;
+  while (path.parent != null) {
+    parts.push(mutator(path.leafName, index++));
+    path = path.parent;
+  }
+
+  while (parts.length > 0) {
+    path.append(parts.pop());
+  }
+
+  return path;
+}
+
+// Counts how many path parts a mutator will be called for.
+function countParts(path) {
+  let index = 0;
+  while (path.parent != null) {
+    path = path.parent;
+    index++;
+  }
+  return index;
+}
+
+add_task(async function testSameBinary() {
+  // Running with the same binary path should definitely work and give the same
+  // hash.
+  Assert.equal(await getHash(EXE), HASH, "Should have the same hash when running the same binary.");
+});
+
+add_task(async function testUpperCase() {
+  let upper = mutatePath(EXE, p => p.toLocaleUpperCase());
+  let hash = await getHash(upper);
+
+  // We may not get a hash if any part of the filesystem is case sensitive.
+  if (hash) {
+    Assert.equal(hash, HASH, `Should have seen the same hash from ${upper.path}.`);
+  }
+});
+
+add_task(async function testLowerCase() {
+  let lower = mutatePath(EXE, p => p.toLocaleLowerCase());
+  let hash = await getHash(lower);
+
+  // We may not get a hash if any part of the filesystem is case sensitive.
+  if (hash) {
+    Assert.equal(hash, HASH, `Should have seen the same hash from ${lower.path}.`);
+  }
+});
+
+add_task(async function testEachPart() {
+  // We need to check the case where only some of the directories in the path
+  // are case insensitive.
+
+  let count = countParts(EXE);
+  for (let i = 0; i < count; i++) {
+    let upper = mutatePath(EXE, (p, index) => index == i ? p.toLocaleUpperCase() : p);
+    let lower = mutatePath(EXE, (p, index) => index == i ? p.toLocaleLowerCase() : p);
+
+    let upperHash = await getHash(upper);
+    if (upperHash) {
+      Assert.equal(upperHash, HASH, `Should have seen the same hash from ${upper.path}.`);
+    }
+
+    let lowerHash = await getHash(lower);
+    if (lowerHash) {
+      Assert.equal(lowerHash, HASH, `Should have seen the same hash from ${lower.path}.`);
+    }
+  }
+});
--- a/toolkit/xre/test/xpcshell.ini
+++ b/toolkit/xre/test/xpcshell.ini
@@ -3,9 +3,14 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 [DEFAULT]
 tags = native
 
 [test_launch_without_hang.js]
 run-sequentially = Has to launch application binary
 skip-if = toolkit == 'android'
-
+[test_install_hash.js]
+# Android doesn't ship Subprocess.jsm and debug builds output garbage that the
+# test cannot handle.
+skip-if = toolkit == 'android' || debug
+support-files =
+  show_hash.js