/ src / modules / launcher / PowerLauncher / Helper / EnvironmentHelper.cs
EnvironmentHelper.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using System;
  6  using System.Collections;
  7  using System.Collections.Generic;
  8  using System.Linq;
  9  using System.Security.Principal;
 10  
 11  using Wox.Plugin.Logger;
 12  
 13  using Stopwatch = Wox.Infrastructure.Stopwatch;
 14  
 15  namespace PowerLauncher.Helper
 16  {
 17      /// <Note>
 18      /// On Windows operating system the name of environment variables is case-insensitive. This means if we have a user and machine variable with differences in their name casing (e.g. test vs Test), the name casing from machine level is used and won't be overwritten by the user var.
 19      /// Example for Window's behavior: test=ValueMachine (Machine level) + TEST=ValueUser (User level) => test=ValueUser (merged)
 20      /// To get the same behavior we use "StringComparer.OrdinalIgnoreCase" as compare property for the HashSet and Dictionaries where we merge machine and user variable names.
 21      /// </Note>
 22      public static class EnvironmentHelper
 23      {
 24          // The HashSet will contain the list of environment variables that will be skipped on update.
 25          private const string PathVariableName = "Path";
 26          private static readonly HashSet<string> _protectedProcessVariables = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 27  
 28          /// <summary>
 29          /// This method is called from <see cref="MainWindow.OnSourceInitialized"/> to initialize a list of protected environment variables right after the PT Run process has been invoked.
 30          /// Protected variables are environment variables that must not be changed on process level when updating the environment variables with changes on machine and/or user level.
 31          /// We cache the relevant variable names in the private, static and readonly variable <see cref="_protectedProcessVariables"/> of this class.
 32          /// </summary>
 33          internal static void GetProtectedEnvironmentVariables()
 34          {
 35              IDictionary processVars;
 36              var machineAndUserVars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 37  
 38              Stopwatch.Normal("EnvironmentHelper.GetProtectedEnvironmentVariables - Duration cost", () =>
 39              {
 40                  // Adding some well known variables that must kept unchanged on process level.
 41                  // Changes of this variables may lead to incorrect values
 42                  _protectedProcessVariables.Add("USERNAME");
 43                  _protectedProcessVariables.Add("PROCESSOR_ARCHITECTURE");
 44  
 45                  // Getting environment variables
 46                  processVars = GetEnvironmentVariablesWithErrorLog(EnvironmentVariableTarget.Process);
 47                  GetMergedMachineAndUserVariables(machineAndUserVars);
 48  
 49                  // Adding names of variables that are different on process level or existing only on process level
 50                  foreach (DictionaryEntry pVar in processVars)
 51                  {
 52                      string pVarKey = (string)pVar.Key;
 53                      string pVarValue = (string)pVar.Value;
 54  
 55                      if (machineAndUserVars.TryGetValue(pVarKey, out string value))
 56                      {
 57                          if (value != pVarValue)
 58                          {
 59                              // Variable value for this process differs form merged machine/user value.
 60                              _protectedProcessVariables.Add(pVarKey);
 61                          }
 62                      }
 63                      else
 64                      {
 65                          // Variable exists only for this process
 66                          _protectedProcessVariables.Add(pVarKey);
 67                      }
 68                  }
 69              });
 70          }
 71  
 72          /// <summary>
 73          /// This method is used as a function wrapper to do the update twice. It is called when we receive a special WindowMessage.
 74          /// </summary>
 75          internal static void UpdateEnvironment()
 76          {
 77              Stopwatch.Normal("EnvironmentHelper.UpdateEnvironment - Duration cost", () =>
 78              {
 79                  // We have to do the update twice to get a correct variable set, if some variables reference other variables in their value (e.g. PATH contains %JAVA_HOME%). [https://github.com/microsoft/PowerToys/issues/26864]
 80                  // The cause of this is a bug in .Net which reads the variables from the Registry (HKLM/HKCU), but expands the REG_EXPAND_SZ values against the current process environment when reading the Registry value.
 81                  ExecuteEnvironmentUpdate();
 82                  ExecuteEnvironmentUpdate();
 83              });
 84          }
 85  
 86          /// <summary>
 87          /// This method updates the environment of PT Run's process when called.
 88          /// </summary>
 89          private static void ExecuteEnvironmentUpdate()
 90          {
 91                  // Caching existing process environment and getting updated environment variables
 92                  IDictionary oldProcessEnvironment = GetEnvironmentVariablesWithErrorLog(EnvironmentVariableTarget.Process);
 93                  var newEnvironment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 94                  GetMergedMachineAndUserVariables(newEnvironment);
 95  
 96                  // Determine deleted variables and add them with a "string.Empty" value as marker to the dictionary
 97                  foreach (DictionaryEntry pVar in oldProcessEnvironment)
 98                  {
 99                      // We must compare case-insensitive (see dictionary assignment) to avoid false positives when the variable name has changed (Example: "path" -> "Path")
100                      if (!newEnvironment.ContainsKey((string)pVar.Key) & !_protectedProcessVariables.Contains((string)pVar.Key))
101                      {
102                          newEnvironment.Add((string)pVar.Key, string.Empty);
103                      }
104                  }
105  
106                  // Remove unchanged variables from the dictionary
107                  // Later we only like to recreate the changed ones
108                  foreach (string varName in newEnvironment.Keys.ToList())
109                  {
110                      // To be able to detect changed names correctly we have to compare case-sensitive
111                      if (oldProcessEnvironment.Contains(varName))
112                      {
113                          if (oldProcessEnvironment[varName].Equals(newEnvironment[varName]))
114                          {
115                              newEnvironment.Remove(varName);
116                          }
117                      }
118                  }
119  
120                  // Update PT Run's process environment now
121                  foreach (KeyValuePair<string, string> kv in newEnvironment)
122                  {
123                      // Initialize variables for length of environment variable name and value. Using this variables prevent us from null value exceptions.
124                      // => We added this because of the issue #13172 where a user reported System.ArgumentNullException from "Environment.SetEnvironmentVariable()".
125                      int varNameLength = kv.Key == null ? 0 : kv.Key.Length;
126                      int varValueLength = kv.Value == null ? 0 : kv.Value.Length;
127  
128                      // The name of environment variables must not be null, empty or have a length of zero.
129                      // But if the value of the environment variable is null or an empty string then the variable is explicit defined for deletion. => Here we don't need to check anything.
130                      // => We added the if statement (next line) because of the issue #13172 where a user reported System.ArgumentNullException from "Environment.SetEnvironmentVariable()".
131                      if (!string.IsNullOrEmpty(kv.Key) & varNameLength > 0)
132                      {
133                          try
134                          {
135                              // If the variable is not listed as protected/don't override on process level, then update it. (See method "GetProtectedEnvironmentVariables" of this class.)
136                              if (!_protectedProcessVariables.Contains(kv.Key))
137                              {
138                                  // We have to delete the variables first that we can update their name if changed by the user. (Example: "path" => "Path")
139                                  // The machine and user variables that have been deleted by the user having an empty string as variable value. Because of this we check the values of the variables in our dictionary against "null" and "string.Empty". This check prevents us from invoking a (second) delete command.
140                                  // The dotnet method doesn't throw an exception if the variable which should be deleted doesn't exist.
141                                  Environment.SetEnvironmentVariable(kv.Key, null, EnvironmentVariableTarget.Process);
142                                  if (!string.IsNullOrEmpty(kv.Value))
143                                  {
144                                      Environment.SetEnvironmentVariable(kv.Key, kv.Value, EnvironmentVariableTarget.Process);
145                                  }
146                              }
147                              else
148                              {
149                                  // Don't log for the variable "USERNAME" if the variable's value is "System". (Then it is a false positive because per default the variable only exists on machine level with the value "System".)
150                                  if (!kv.Key.Equals("USERNAME", StringComparison.OrdinalIgnoreCase) & !kv.Value.Equals("System", StringComparison.Ordinal))
151                                  {
152                                      Log.Warn($"Skipping update of the environment variable [{kv.Key}] for the PT Run process. This variable is listed as protected process variable and changing them can cause unexpected behavior. (The variable value has a length of [{varValueLength}].)", typeof(PowerLauncher.Helper.EnvironmentHelper));
153                                  }
154                              }
155                          }
156                          catch (Exception ex)
157                          {
158                              // The dotnet method "System.Environment.SetEnvironmentVariable" has its own internal method to check the input parameters. Here we catch the exceptions that we don't check before updating the environment variable and log it to avoid crashes of PT Run.
159                              Log.Exception($"Unhandled exception while updating the environment variable [{kv.Key}] for the PT Run process. (The variable value has a length of [{varValueLength}].)", ex, typeof(PowerLauncher.Helper.EnvironmentHelper));
160                          }
161                      }
162                      else
163                      {
164                          // Log the error when variable name is null, empty or has a length of zero.
165                          Log.Error($"Failed to update the environment variable [{kv.Key}] for the PT Run process. Their name is null or empty. (The variable value has a length of [{varValueLength}].)", typeof(PowerLauncher.Helper.EnvironmentHelper));
166                      }
167                  }
168          }
169  
170          /// <summary>
171          /// This method returns a Dictionary with a merged set of machine and user environment variables. If we run as "system" only machine variables are returned.
172          /// </summary>
173          /// <param name="environment">The dictionary that should be filled with the merged variables.</param>
174          private static void GetMergedMachineAndUserVariables(Dictionary<string, string> environment)
175          {
176              // Getting machine variables
177              IDictionary machineVars = GetEnvironmentVariablesWithErrorLog(EnvironmentVariableTarget.Machine);
178              foreach (DictionaryEntry mVar in machineVars)
179              {
180                  environment[(string)mVar.Key] = (string)mVar.Value;
181              }
182  
183              // Getting user variables and merge it
184              if (!IsRunningAsSystem())
185              {
186                  IDictionary userVars = GetEnvironmentVariablesWithErrorLog(EnvironmentVariableTarget.User);
187                  foreach (DictionaryEntry uVar in userVars)
188                  {
189                      string uVarKey = (string)uVar.Key;
190                      string uVarValue = (string)uVar.Value;
191  
192                      // The variable name of the path variable can be upper case, lower case ore mixed case. So we have to compare case-insensitive.
193                      if (!uVarKey.Equals(PathVariableName, StringComparison.OrdinalIgnoreCase))
194                      {
195                          environment[uVarKey] = uVarValue;
196                      }
197                      else
198                      {
199                          // Checking if the list of (machine) variables contains a path variable
200                          if (environment.ContainsKey(PathVariableName))
201                          {
202                              // When we merging the PATH variables we can't simply overwrite machine layer's value. The path variable must be joined by appending the user value to the machine value.
203                              // This is the official behavior and checked by trying it out on the physical machine.
204                              string newPathValue = environment[uVarKey].EndsWith(';') ? environment[uVarKey] + uVarValue : environment[uVarKey] + ';' + uVarValue;
205                              environment[uVarKey] = newPathValue;
206                          }
207                          else
208                          {
209                              // Log warning and only write user value into dictionary
210                              Log.Warn("The List of machine variables doesn't contain a path variable! The merged list won't contain any machine paths in the path variable.", typeof(PowerLauncher.Helper.EnvironmentHelper));
211                              environment[uVarKey] = uVarValue;
212                          }
213                      }
214                  }
215              }
216          }
217  
218          /// <summary>
219          /// Returns the variables for the specified target. Errors that occurs will be caught and logged.
220          /// </summary>
221          /// <param name="target">The target variable source of the type <see cref="EnvironmentVariableTarget"/> </param>
222          /// <returns>A dictionary with the variable or an empty dictionary on errors.</returns>
223          private static IDictionary GetEnvironmentVariablesWithErrorLog(EnvironmentVariableTarget target)
224          {
225              try
226              {
227                  return Environment.GetEnvironmentVariables(target);
228              }
229              catch (Exception ex)
230              {
231                  Log.Exception($"Unhandled exception while getting the environment variables for target '{target}'.", ex, typeof(PowerLauncher.Helper.EnvironmentHelper));
232                  return new Hashtable();
233              }
234          }
235  
236          /// <summary>
237          /// Checks whether this process is running under the system user/account.
238          /// </summary>
239          /// <returns>A boolean value that indicates whether this process is running under system account (true) or not (false).</returns>
240          private static bool IsRunningAsSystem()
241          {
242              using (var identity = WindowsIdentity.GetCurrent())
243              {
244                  return identity.IsSystem;
245              }
246          }
247      }
248  }