diff --git a/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs b/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs index de5d7215e8..6335230d1f 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs +++ b/src/Microsoft.PowerShell.ConsoleHost/host/msh/CommandLineParameterParser.cs @@ -183,7 +183,7 @@ namespace Microsoft.PowerShell "file", "help", "inputformat", - "loadprofile", + "login", "noexit", "nologo", "noninteractive", @@ -710,21 +710,23 @@ namespace Microsoft.PowerShell _noExit = false; break; } - else if (MatchSwitch(switchKey, "help", "h") || MatchSwitch(switchKey, "?", "?")) + + if (MatchSwitch(switchKey, "help", "h") || MatchSwitch(switchKey, "?", "?")) { _showHelp = true; _showExtendedHelp = true; _abortStartup = true; } + else if (MatchSwitch(switchKey, "login", "l")) + { + // This handles -Login on Windows only, where it does nothing. + // On *nix, -Login is handled much earlier to improve startup performance. + } else if (MatchSwitch(switchKey, "noexit", "noe")) { _noExit = true; noexitSeen = true; } - else if (MatchSwitch(switchKey, "loadprofile", "l")) - { - _skipUserInit = false; - } else if (MatchSwitch(switchKey, "noprofile", "nop")) { _skipUserInit = true; diff --git a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx index 074b926838..2b27cb19a4 100644 --- a/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx +++ b/src/Microsoft.PowerShell.ConsoleHost/resources/ManagedEntranceStrings.resx @@ -125,14 +125,14 @@ https://aka.ms/powershell Type 'help' to get help. - Usage: pwsh[.exe] [[-File] <filePath> [args]] + Usage: pwsh[.exe] [-Login] [[-File] <filePath> [args]] [-Command { - | <script-block> [-args <arg-array>] | <string> [<CommandParameters>] } ] [-ConfigurationName <string>] [-CustomPipeName <string>] [-EncodedCommand <Base64EncodedCommand>] [-ExecutionPolicy <ExecutionPolicy>] [-InputFormat {Text | XML}] - [-Interactive] [-LoadProfile] [-MTA] [-NoExit] [-NoLogo] [-NonInteractive] [-NoProfile] - [-OutputFormat {Text | XML}] [-SettingsFile <filePath>] [-STA] [-Version] + [-Interactive] [-MTA] [-NoExit] [-NoLogo] [-NonInteractive] [-NoProfile] + [-OutputFormat {Text | XML}] [-SettingsFile <filePath>] [-STA] [-Version] [-WindowStyle <style>] [-WorkingDirectory <directoryPath>] pwsh[.exe] -h | -Help | -? | /? @@ -293,10 +293,13 @@ All parameters are case-insensitive. Present an interactive prompt to the user. Inverse for NonInteractive parameter. --LoadProfile | -l +-Login | -l - Load the PowerShell profiles. This is the default behavior even if this is - not specified. + On Linux and macOS, starts PowerShell as a login shell, + using /bin/sh to execute login profiles such as /etc/profile and ~/.profile. + On Windows, this switch does nothing. + + Note that "-Login" is only supported as the first parameter to pwsh. -MTA diff --git a/src/powershell/Program.cs b/src/powershell/Program.cs index 94560f25e0..99834e6a95 100644 --- a/src/powershell/Program.cs +++ b/src/powershell/Program.cs @@ -2,7 +2,9 @@ // Licensed under the MIT License. using System; +using System.IO; using System.Reflection; +using System.Runtime.InteropServices; namespace Microsoft.PowerShell { @@ -11,6 +13,48 @@ namespace Microsoft.PowerShell /// public sealed class ManagedPSEntry { +#if UNIX + /// + /// Exception to signify an early startup failure. + /// + private class StartupException : Exception + { + /// + /// Construct a new startup exception instance. + /// + /// The name of the native call that failed. + /// The exit code the native call returned. + public StartupException(string callName, int exitCode) + { + CallName = callName; + ExitCode = exitCode; + } + + /// + /// The name of the native call that failed. + /// + public string CallName { get; } + + /// + /// The exit code returned by the failed native call. + /// + public int ExitCode { get; } + } + + // Environment variable used to short circuit second login check + private const string LOGIN_ENV_VAR_NAME = "__PWSH_LOGIN_CHECKED"; + private const string LOGIN_ENV_VAR_VALUE = "1"; + + // Linux p/Invoke constants + private const int LINUX_PATH_MAX = 4096; + + // MacOS p/Invoke constants + private const int MACOS_CTL_KERN = 1; + private const int MACOS_KERN_ARGMAX = 8; + private const int MACOS_KERN_PROCARGS2 = 49; + private const int MACOS_PROC_PIDPATHINFO_MAXSIZE = 4096; +#endif + /// /// Starts the managed MSH. /// @@ -19,7 +63,434 @@ namespace Microsoft.PowerShell /// public static int Main(string[] args) { +#if UNIX + AttemptExecPwshLogin(args); +#endif return UnmanagedPSEntry.Start(string.Empty, args, args.Length); } + +#if UNIX + /// + /// Checks whether pwsh has been started as a login shell + /// and if so, proceeds with the login process. + /// This method will return early if pwsh was not started as a login shell + /// and will throw if it detects a native call has failed. + /// In the event of success, we use an exec() call, so this method never returns. + /// + /// The startup arguments to pwsh. + private static void AttemptExecPwshLogin(string[] args) + { + // If the login environment variable is set, we have already done the login logic and have been exec'd + if (Environment.GetEnvironmentVariable(LOGIN_ENV_VAR_NAME) != null) + { + Environment.SetEnvironmentVariable(LOGIN_ENV_VAR_NAME, null); + return; + } + + bool isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + // The first byte (ASCII char) of the name of this process, used to detect '-' for login + byte procNameFirstByte; + + // The path to the executable this process was started from + string pwshPath; + + // On Linux, we can simply use the /proc filesystem + if (isLinux) + { + // Read the process name byte + using (FileStream fs = File.OpenRead("/proc/self/cmdline")) + { + procNameFirstByte = (byte)fs.ReadByte(); + } + + // Run login detection logic + if (!IsLogin(procNameFirstByte, args)) + { + return; + } + + // Read the symlink to the startup executable + IntPtr linkPathPtr = Marshal.AllocHGlobal(LINUX_PATH_MAX); + IntPtr bufSize = ReadLink("/proc/self/exe", linkPathPtr, (UIntPtr)LINUX_PATH_MAX); + pwshPath = Marshal.PtrToStringAnsi(linkPathPtr, (int)bufSize); + Marshal.FreeHGlobal(linkPathPtr); + + // exec pwsh + ThrowOnFailure("exec", ExecPwshLogin(args, pwshPath, isMacOS: false)); + return; + } + + // At this point, we are on macOS + + // Set up the mib array and the query for process maximum args size + Span mib = stackalloc int[3]; + int mibLength = 2; + mib[0] = MACOS_CTL_KERN; + mib[1] = MACOS_KERN_ARGMAX; + int size = IntPtr.Size / 2; + int argmax = 0; + + // Get the process args size + unsafe + { + fixed (int *mibptr = mib) + { + ThrowOnFailure(nameof(argmax), SysCtl(mibptr, mibLength, &argmax, &size, IntPtr.Zero, 0)); + } + } + + // Get the PID so we can query this process' args + int pid = GetPid(); + + // The following logic is based on https://gist.github.com/nonowarn/770696 + + // Now read the process args into the allocated space + IntPtr procargs = Marshal.AllocHGlobal(argmax); + IntPtr executablePathPtr = IntPtr.Zero; + try + { + mib[0] = MACOS_CTL_KERN; + mib[1] = MACOS_KERN_PROCARGS2; + mib[2] = pid; + mibLength = 3; + + unsafe + { + fixed (int *mibptr = mib) + { + ThrowOnFailure(nameof(procargs), SysCtl(mibptr, mibLength, procargs.ToPointer(), &argmax, IntPtr.Zero, 0)); + } + + // The memory block we're reading is a series of null-terminated strings + // that looks something like this: + // + // | argc | + // | exec_path | ... \0 + // | argv[0] | ... \0 + // | argv[1] | ... \0 + // ... + // + // We care about argv[0], since that's the name the process was started with. + // If argv[0][0] == '-', we have been invoked as login. + // Doing this, the buffer we populated also recorded `exec_path`, + // which is the path to our executable `pwsh`. + // We can reuse this value later to prevent needing to call a .NET API + // to generate our exec invocation. + + + // We don't care about argc's value, since argv[0] must always exist. + // Skip over argc, but remember where exec_path is for later + executablePathPtr = IntPtr.Add(procargs, sizeof(int)); + + // Skip over exec_path + byte *argvPtr = (byte *)executablePathPtr; + while (*argvPtr != 0) { argvPtr++; } + while (*argvPtr == 0) { argvPtr++; } + + // First char in argv[0] + procNameFirstByte = *argvPtr; + } + + if (!IsLogin(procNameFirstByte, args)) + { + return; + } + + // Get the pwshPath from exec_path + pwshPath = Marshal.PtrToStringAnsi(executablePathPtr); + + // exec pwsh + ThrowOnFailure("exec", ExecPwshLogin(args, pwshPath, isMacOS: true)); + } + finally + { + Marshal.FreeHGlobal(procargs); + } + } + + /// + /// Checks args to see if -Login has been specified. + /// + /// The first byte of the name of the currently running process. + /// Arguments passed to the program. + /// + private static bool IsLogin( + byte procNameFirstByte, + string[] args) + { + // Process name starting with '-' means this is a login shell + if (procNameFirstByte == 0x2D) + { + return true; + } + + // Look at the first parameter to see if it is -Login + // NOTE: -Login is only supported as the first parameter to PowerShell + return args.Length > 0 + && args[0].Length > 1 + && args[0][0] == '-' + && IsParam(args[0], "login", "LOGIN"); + } + + /// + /// Determines if a given parameter is the one we're looking for. + /// Assumes any prefix determines that parameter (true for -l, -c and -f). + /// + /// The argument to check. + /// The lowercase name of the parameter to check. + /// The uppercase name of the parameter to check. + /// + private static bool IsParam( + string arg, + string paramToCheck, + string paramToCheckUpper) + { + // Quick fail if the argument is longer than the parameter + if (arg.Length > paramToCheck.Length + 1) + { + return false; + } + + // Check arg chars in order and allow prefixes + for (int i = 1; i < arg.Length; i++) + { + if (arg[i] != paramToCheck[i-1] + && arg[i] != paramToCheckUpper[i-1]) + { + return false; + } + } + + return true; + } + + /// + /// Create the exec call to /bin/{z}sh -l -c 'exec pwsh "$@"' and run it. + /// + /// The argument vector passed to pwsh. + /// True if we are running on macOS. + /// Absolute path to the pwsh executable. + /// + /// The exit code of exec if it fails. + /// If exec succeeds, this process is overwritten so we never actually return. + /// + private static int ExecPwshLogin(string[] args, string pwshPath, bool isMacOS) + { + // Create input for /bin/sh that execs pwsh + int quotedPwshPathLength = GetQuotedPathLength(pwshPath); + + string pwshInvocation = string.Create( + quotedPwshPathLength + 10, // exec '{pwshPath}' "$@" + (pwshPath, quotedPwshPathLength), + CreatePwshInvocation); + + // Set up the arguments for '/bin/sh'. + // We need to add 5 slots for the '/bin/sh' invocation parts, plus 1 slot for the null terminator at the end + var execArgs = new string[args.Length + 6]; + + // The command arguments + + // First argument is the command name. + // Even when executing 'zsh', we want to set this to '/bin/sh' + // because this tells 'zsh' to run in sh emulation mode (it examines $0) + execArgs[0] = "/bin/sh"; + + execArgs[1] = "-l"; // Login flag + execArgs[2] = "-c"; // Command parameter + execArgs[3] = pwshInvocation; // Command to execute + + // The /bin/sh option spec looks like: + // sh -c command_string [command_name [argument...]] + // We must provide a command_name before arguments, + // but this is never used since "$@" takes argv[1] - argv[n] + // and the `exec` builtin provides its own argv[0]. + // See https://pubs.opengroup.org/onlinepubs/9699919799.2016edition/ + // + // Since command_name is ignored and we can't use null (it's the terminator) + // we use empty string + execArgs[4] = ""; + + // Add the arguments passed to pwsh on the end. + args.CopyTo(execArgs, 5); + + // A null is required by exec. + execArgs[execArgs.Length - 1] = null; + + // We can't use Environment.SetEnvironmentVariable() here. + // See https://github.com/dotnet/corefx/issues/40130#issuecomment-519420648. + ThrowOnFailure("setenv", SetEnv(LOGIN_ENV_VAR_NAME, LOGIN_ENV_VAR_VALUE, overwrite: true)); + + // On macOS, sh doesn't support login, so we run /bin/zsh in sh emulation mode. + if (isMacOS) + { + return Exec("/bin/zsh", execArgs); + } + + return Exec("/bin/sh", execArgs); + } + + /// + /// Gets what the length of the given string will be if it's + /// quote escaped for /bin/sh. + /// + /// The string to quote escape. + /// The length of the string when it's quote escaped. + private static int GetQuotedPathLength(string str) + { + int length = 2; + foreach (char c in str) + { + length++; + if (c == '\'') { length++; } + } + + return length; + } + + /// + /// Implements a SpanAction<T> for string.Create() + /// that builds the shell invocation for the login pwsh session. + /// + /// The buffer of the string to be created. + /// Information used to build the required string. + private static void CreatePwshInvocation( + Span strBuf, + (string path, int quotedLength) invocationInfo) + { + // "exec " + string prefix = "exec "; + prefix.AsSpan().CopyTo(strBuf); + + // The quoted path to pwsh, like "'/opt/microsoft/powershell/7/pwsh'" + int i = prefix.Length; + Span pathSpan = strBuf.Slice(i, invocationInfo.quotedLength); + QuoteAndWriteToSpan(invocationInfo.path, pathSpan); + i += invocationInfo.quotedLength; + + // ' "$@"' the argument vector splat to pass pwsh arguments through + string suffix = " \"$@\""; + Span bufSuffix = strBuf.Slice(i); + suffix.AsSpan().CopyTo(bufSuffix); + } + + /// + /// Quotes (and sh quote escapes) a string and writes it to the given span. + /// + /// The string to quote. + /// The span to write to. + private static void QuoteAndWriteToSpan(string arg, Span span) + { + span[0] = '\''; + + int i = 0; + int j = 1; + for (; i < arg.Length; i++, j++) + { + char c = arg[i]; + + if (c == '\'') + { + // /bin/sh quote escaping uses backslashes + span[j] = '\\'; + j++; + } + + span[j] = c; + } + + span[j] = '\''; + } + + /// + /// If the given exit code is negative, throws a StartupException. + /// + /// The native call that was attempted. + /// The exit code it returned. + private static void ThrowOnFailure(string call, int code) + { + if (code < 0) + { + code = Marshal.GetLastWin32Error(); + Console.Error.WriteLine($"Call to '{call}' failed with errno {code}"); + throw new StartupException(call, code); + } + } + + /// + /// The `execv` POSIX syscall we use to exec /bin/sh. + /// + /// The path to the executable to exec. + /// + /// The arguments to send through to the executable. + /// Array must have its final element be null. + /// + /// + /// An exit code if exec failed, but if successful the calling process will be overwritten. + /// + [DllImport("libc", + EntryPoint = "execv", + CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Ansi, + SetLastError = true)] + private static extern int Exec(string path, string[] args); + + /// + /// The `readlink` POSIX syscall we use to read the symlink from /proc/self/exe + /// to get the executable path of pwsh on Linux. + /// + /// The path to the symlink to read. + /// Pointer to a buffer to fill with the result. + /// The size of the buffer we have supplied. + /// The number of bytes placed in the buffer. + [DllImport("libc", + EntryPoint = "readlink", + CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Ansi, + SetLastError = true)] + private static extern IntPtr ReadLink(string pathname, IntPtr buf, UIntPtr size); + + /// + /// The `getpid` POSIX syscall we use to quickly get the current process PID on macOS. + /// + /// The pid of the current process. + [DllImport("libc", + EntryPoint = "getpid", + CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Ansi, + SetLastError = true)] + private static extern int GetPid(); + + /// + /// The `setenv` POSIX syscall used to set an environment variable in the process. + /// + /// The name of the environment variable. + /// The value of the environment variable. + /// If true, will overwrite an existing environment variable of the same name. + /// 0 if successful, -1 on error. errno indicates the reason for failure. + [DllImport("libc", + EntryPoint = "setenv", + CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Ansi, + SetLastError = true)] + private static extern int SetEnv(string name, string value, bool overwrite); + + /// + /// The `sysctl` BSD sycall used to get system information on macOS. + /// + /// The Management Information Base name, used to query information. + /// The length of the MIB name. + /// The object passed out of sysctl (may be null) + /// The size of the object passed out of sysctl. + /// The object passed in to sysctl. + /// The length of the object passed in to sysctl. + /// + [DllImport("libc", + EntryPoint = "sysctl", + CallingConvention = CallingConvention.Cdecl, + CharSet = CharSet.Ansi, + SetLastError = true)] + private static unsafe extern int SysCtl(int *mib, int mibLength, void *oldp, int *oldlenp, IntPtr newp, int newlenp); +#endif } } diff --git a/test/powershell/Host/ConsoleHost.Tests.ps1 b/test/powershell/Host/ConsoleHost.Tests.ps1 index 4bbc135770..f5547d4cab 100644 --- a/test/powershell/Host/ConsoleHost.Tests.ps1 +++ b/test/powershell/Host/ConsoleHost.Tests.ps1 @@ -245,42 +245,79 @@ Describe "ConsoleHost unit tests" -tags "Feature" { } } - Context "-LoadProfile Commandline switch" { + Context "-Login pwsh switch" { BeforeAll { - if (Test-Path $profile) { - Remove-Item -Path "$profile.backup" -ErrorAction SilentlyContinue - Rename-Item -Path $profile -NewName "$profile.backup" + $profilePath = "~/.profile" + $backupProfilePath = "profile.bak" + if (Test-Path $profilePath) { + Move-Item -Path $profilePath -Destination $backupProfilePath -Force } - Set-Content -Path $profile -Value "'profile-loaded'" -Force + $envVarName = 'PSTEST_PROFILE_LOAD' + + $guid = New-Guid + + Set-Content -Force -Path $profilePath -Value @" +export $envVarName='$guid' +"@ } AfterAll { - Remove-Item -Path $profile -ErrorAction SilentlyContinue - - if (Test-Path "$profile.backup") { - Rename-Item -Path "$profile.backup" -NewName $profile + if (Test-Path $backupProfilePath) { + Move-Item -Path $backupProfilePath -Destination $profilePath -Force } } - It "Verifies pwsh will accept switch" -TestCases @( - @{ switch = "-l"}, - @{ switch = "-loadprofile"} - ){ - param($switch) + It "Doesn't run the login profile when -Login not used" { + $result = & $powershell -Command "`$env:$envVarName" + $result | Should -BeNullOrEmpty + $LASTEXITCODE | Should -Be 0 + } - if (Test-Path $profile) { - & pwsh $switch -command exit | Should -BeExactly "profile-loaded" - } - else { - # In CI, may not be able to write to $profile location, so just verify that the switch is accepted - # and no error message is in the output - & pwsh $switch -command exit *>&1 | Should -BeNullOrEmpty + It "Doesn't falsely recognise -Login when elsewhere in the invocation" { + $result = & $powershell -nop -c 'Write-Output "-login"' + $result | Should -BeExactly '-login' + $LASTEXITCODE | Should -Be 0 + } + + It "Doesn't falsely recognise -Login when used after -Command" { + $result = & $powershell -nop -c 'Write-Output' -Login + $result | Should -BeExactly '-Login' + $LASTEXITCODE | Should -Be 0 + } + + It "Accepts the switch for -Login and behaves correctly" -TestCases @( + @{ LoginSwitch = '-l' } + @{ LoginSwitch = '-L' } + @{ LoginSwitch = '-login' } + @{ LoginSwitch = '-Login' } + @{ LoginSwitch = '-LOGIN' } + @{ LoginSwitch = '-log' } + ) { + param($LoginSwitch) + + $result = & $powershell $LoginSwitch -NoProfile -Command "`$env:$envVarName" + + if ($IsWindows) { + $result | Should -BeNullOrEmpty + $LASTEXITCODE | Should -Be 0 + return } + + $result | Should -BeExactly $guid + $LASTEXITCODE | Should -Be 0 + } + + It "Starts as a login shell with '-' prepended to name" -Skip:(-not (Get-Command -Name /bin/bash -ErrorAction Ignore)) { + $quoteEscapedPwsh = $powershell.Replace("'", "\'") + $pwshCommand = "`$env:$envVarName" + $bashCommand = "exec -a '-pwsh' '$quoteEscapedPwsh' -NoProfile -Command '`$env:$envVarName' ''" + $result = /bin/bash -c $bashCommand + $result | Should -BeExactly $guid + $LASTEXITCODE | Should -Be 0 # Exit code will be PowerShell's since it was exec'd } } - Context "-SettingsFile Commandline switch" { BeforeAll {