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 }