Skip to content

Commit f4adac4

Browse files
committed
Implement a native TTY interface for POSIX platforms
Replace the `Console.Read/Write` interface for terminal prompts with a system-native implementation that uses /dev/tty directly. This allows us to read and write to the terminal even when our stdin/out/err streams have been redirected by parent processes. We can also now exercise control over the TTY echo which is useful for hiding user input of passwords and other secrets.
1 parent 9f4b2dd commit f4adac4

File tree

15 files changed

+707
-94
lines changed

15 files changed

+707
-94
lines changed

src/shared/GitHub/GitHubAuthentication.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ public bool TryGetCredentials(Uri targetUri, out string userName, out string pas
2727
{
2828
EnsureTerminalPromptsEnabled();
2929

30-
_context.StdError.WriteLine("Enter credentials for '{0}'...", targetUri);
30+
_context.Terminal.WriteLine("Enter credentials for '{0}'...", targetUri);
3131

32-
userName = _context.Prompt("Username");
33-
password = _context.PromptSecret("Password");
32+
userName = _context.Terminal.Prompt("Username");
33+
password = _context.Terminal.PromptSecret("Password");
3434

3535
return !string.IsNullOrWhiteSpace(userName) && !string.IsNullOrWhiteSpace(password);
3636
}
@@ -39,18 +39,18 @@ public bool TryGetAuthenticationCode(Uri targetUri, bool isSms, out string authe
3939
{
4040
EnsureTerminalPromptsEnabled();
4141

42-
_context.StdError.WriteLine("Two-factor authentication is enabled and an authentication code is required.");
42+
_context.Terminal.WriteLine("Two-factor authentication is enabled and an authentication code is required.");
4343

4444
if (isSms)
4545
{
46-
_context.StdError.WriteLine("An SMS containing the authentication code has been sent to your registered device.");
46+
_context.Terminal.WriteLine("An SMS containing the authentication code has been sent to your registered device.");
4747
}
4848
else
4949
{
50-
_context.StdError.WriteLine("Use your registered authentication app to generate an authentication code.");
50+
_context.Terminal.WriteLine("Use your registered authentication app to generate an authentication code.");
5151
}
5252

53-
authenticationCode = _context.Prompt("Authentication code");
53+
authenticationCode = _context.Terminal.Prompt("Authentication code");
5454
return !string.IsNullOrWhiteSpace(authenticationCode);
5555
}
5656

src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/TtyPromptBasicAuthenticationTests.cs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,8 @@ public void TtyPromptBasicAuthentication_GetCredentials_ResourceAndUserName_Pass
2828
const string testUserName = "john.doe";
2929
const string testPassword = "letmein123";
3030

31-
var context = new TestCommandContext
32-
{
33-
SecretPrompts = {["Password"] = testPassword}
34-
};
31+
var context = new TestCommandContext();
32+
context.Terminal.SecretPrompts["Password"] = testPassword;
3533

3634
var basicAuth = new TtyPromptBasicAuthentication(context);
3735

@@ -48,11 +46,9 @@ public void TtyPromptBasicAuthentication_GetCredentials_Resource_UserPassPromptR
4846
const string testUserName = "john.doe";
4947
const string testPassword = "letmein123";
5048

51-
var context = new TestCommandContext
52-
{
53-
Prompts = {["Username"] = testUserName},
54-
SecretPrompts = {["Password"] = testPassword}
55-
};
49+
var context = new TestCommandContext();
50+
context.Terminal.Prompts["Username"] = testUserName;
51+
context.Terminal.SecretPrompts["Password"] = testPassword;
5652

5753
var basicAuth = new TtyPromptBasicAuthentication(context);
5854

src/shared/Microsoft.Git.CredentialManager/Authentication/BasicAuthentication.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,21 @@ public GitCredential GetCredentials(string resource, string userName)
4343
throw new InvalidOperationException("Cannot show basic credential prompt because terminal prompts have been disabled.");
4444
}
4545

46-
_context.StdError.WriteLine("Enter credentials for '{0}':", resource);
46+
_context.Terminal.WriteLine("Enter credentials for '{0}':", resource);
4747

4848
if (!string.IsNullOrWhiteSpace(userName))
4949
{
5050
// Don't need to prompt for the username if it has been specified already
51-
_context.StdError.WriteLine("Username: {0}", userName);
51+
_context.Terminal.WriteLine("Username: {0}", userName);
5252
}
5353
else
5454
{
5555
// Prompt for username
56-
userName = _context.Prompt("Username");
56+
userName = _context.Terminal.Prompt("Username");
5757
}
5858

5959
// Prompt for password
60-
string password = _context.PromptSecret("Password");
60+
string password = _context.Terminal.PromptSecret("Password");
6161

6262
return new GitCredential(userName, password);
6363
}

src/shared/Microsoft.Git.CredentialManager/CommandContext.cs

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.IO;
77
using System.Text;
88
using Microsoft.Git.CredentialManager.Interop.MacOS;
9+
using Microsoft.Git.CredentialManager.Interop.Posix;
910
using Microsoft.Git.CredentialManager.Interop.Windows;
1011

1112
namespace Microsoft.Git.CredentialManager
@@ -31,23 +32,9 @@ public interface ICommandContext
3132
TextWriter StdError { get; }
3233

3334
/// <summary>
34-
/// Shows a prompt and reads input.
35+
/// The attached terminal (TTY) to this process tree.
3536
/// </summary>
36-
/// <param name="prompt">The prompt text to show.</param>
37-
/// <returns>The result from the prompt.</returns>
38-
string Prompt(string prompt);
39-
40-
/// <summary>
41-
/// Shows a prompt for capturing secret/sensitive information such as passwords, suppresses key echo,
42-
/// and reads the input.
43-
/// </summary>
44-
/// <param name="prompt">The prompt text to show.</param>
45-
/// <returns>The result from the prompt.</returns>
46-
/// <exception cref="T:System.InvalidOperationException">
47-
/// If <see cref="echo"/> is false, and the <see cref="System.Console.In"/> property is redirected from some
48-
/// stream other than the console.
49-
/// </exception>
50-
string PromptSecret(string prompt);
37+
ITerminal Terminal { get; }
5138

5239
/// <summary>
5340
/// Application tracing system.
@@ -83,6 +70,7 @@ public class CommandContext : ICommandContext
8370
private TextReader _stdIn;
8471
private TextWriter _stdOut;
8572
private TextWriter _stdErr;
73+
private ITerminal _terminal;
8674

8775
#region ICommandContext
8876

@@ -133,44 +121,9 @@ public TextWriter StdError
133121
}
134122
}
135123

136-
public string Prompt(string prompt)
137-
{
138-
StdError.Write($"{prompt}: ");
139-
140-
return StdIn.ReadLine();
141-
}
142-
143-
public string PromptSecret(string prompt)
124+
public ITerminal Terminal
144125
{
145-
StdError.Write($"{prompt}: ");
146-
147-
var value = new StringBuilder();
148-
bool done = false;
149-
150-
do
151-
{
152-
// TODO: Can & should we directly disable 'stdin echo' and then just use a
153-
// inStream/StdIn.ReadLine() call rather than needing to use Console.ReadKey?
154-
ConsoleKeyInfo keyInfo = Console.ReadKey(intercept: true);
155-
switch (keyInfo.Key)
156-
{
157-
case ConsoleKey.Enter:
158-
done = true;
159-
StdError.WriteLine();
160-
break;
161-
case ConsoleKey.Backspace:
162-
if (value.Length > 0)
163-
{
164-
value.Remove(value.Length - 1, 1);
165-
}
166-
break;
167-
default:
168-
value.Append(keyInfo.KeyChar);
169-
break;
170-
}
171-
} while (!done);
172-
173-
return value.ToString();
126+
get { return _terminal ?? (_terminal = CreateTerminal()); }
174127
}
175128

176129
public ITrace Trace { get; } = new Trace();
@@ -210,6 +163,21 @@ public IReadOnlyDictionary<string, string> GetEnvironmentVariables()
210163

211164
#endregion
212165

166+
private ITerminal CreateTerminal()
167+
{
168+
if (PlatformUtils.IsPosix())
169+
{
170+
return new PosixTerminal(Trace);
171+
}
172+
173+
if (PlatformUtils.IsWindows())
174+
{
175+
throw new NotImplementedException();
176+
}
177+
178+
throw new PlatformNotSupportedException();
179+
}
180+
213181
private static ICredentialStore CreateCredentialStore()
214182
{
215183
if (PlatformUtils.IsMacOS())
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using Microsoft.Git.CredentialManager.Interop;
4+
5+
namespace Microsoft.Git.CredentialManager
6+
{
7+
/// <summary>
8+
/// Represents a terminal (TTY) interface.
9+
/// </summary>
10+
public interface ITerminal
11+
{
12+
/// <summary>
13+
/// Write a message to the terminal screen.
14+
/// </summary>
15+
/// <param name="format">Format message to print to the terminal.</param>
16+
/// <param name="args">Format argument values.</param>
17+
/// <exception cref="InteropException">Throw if an error occurs interacting with the native terminal device.</exception>
18+
void WriteLine(string format, params object[] args);
19+
20+
/// <summary>
21+
/// Prompt for user input.
22+
/// </summary>
23+
/// <param name="prompt">Prompt message.</param>
24+
/// <returns>User input.</returns>
25+
/// <exception cref="InteropException">Throw if an error occurs interacting with the native terminal device.</exception>
26+
string Prompt(string prompt);
27+
28+
/// <summary>
29+
/// Prompt for secret user input.
30+
/// </summary>
31+
/// <remarks>
32+
/// Typed user input is masked or hidden.
33+
/// </remarks>
34+
/// <param name="prompt">Prompt message.</param>
35+
/// <returns>Secret user input.</returns>
36+
/// <exception cref="InteropException">Throw if an error occurs interacting with the native terminal device.</exception>
37+
string PromptSecret(string prompt);
38+
}
39+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using System.Runtime.InteropServices;
5+
6+
namespace Microsoft.Git.CredentialManager.Interop.Posix.Native
7+
{
8+
public static class Fcntl
9+
{
10+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
11+
public static extern int open(string pathname, OpenFlags flags);
12+
}
13+
14+
[Flags]
15+
public enum OpenFlags
16+
{
17+
O_RDONLY = 0,
18+
O_WRONLY = 1,
19+
O_RDWR = 2,
20+
O_CREAT = 64,
21+
O_EXCL = 128,
22+
O_NOCTTY = 256,
23+
O_TRUNC = 512,
24+
O_APPEND = 1024,
25+
O_NONBLOCK = 2048,
26+
O_SYNC = 4096,
27+
O_NOFOLLOW = 131072,
28+
O_DIRECTORY = 65536,
29+
O_DIRECT = 16384,
30+
O_ASYNC = 8192,
31+
O_LARGEFILE = 32768,
32+
O_CLOEXEC = 524288,
33+
O_PATH = 2097152,
34+
}
35+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System.Runtime.InteropServices;
4+
5+
namespace Microsoft.Git.CredentialManager.Interop.Posix.Native
6+
{
7+
public static class Signal
8+
{
9+
/// <summary>
10+
/// Interrupt.
11+
/// </summary>
12+
public const int SIGINT = 2;
13+
14+
/// <summary>
15+
/// Quit.
16+
/// </summary>
17+
public const int SIGQUIT = 3;
18+
19+
/// <summary>
20+
/// Abort.
21+
/// </summary>
22+
public const int SIGABRT = 6;
23+
24+
/// <summary>
25+
/// Kill (cannot be caught or ignored).
26+
/// </summary>
27+
public const int SIGKILL = 9;
28+
29+
/// <summary>
30+
/// Software termination signal from kill.
31+
/// </summary>
32+
public const int SIGTERM = 15;
33+
34+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
35+
public static extern void kill(int pid, int sig);
36+
}
37+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using System.Runtime.InteropServices;
5+
using System.Text;
6+
7+
namespace Microsoft.Git.CredentialManager.Interop.Posix.Native
8+
{
9+
public static class Stdio
10+
{
11+
public const int EOF = -1;
12+
13+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
14+
public static extern IntPtr fgets(StringBuilder sb, int size, IntPtr stream);
15+
16+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
17+
public static extern int fputc(int c, IntPtr stream);
18+
19+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
20+
public static extern int fprintf(IntPtr stream, string format, string message);
21+
22+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
23+
public static extern int fgetc(IntPtr stream);
24+
25+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
26+
public static extern int fputs(string str, IntPtr stream);
27+
28+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
29+
public static extern void setbuf(IntPtr stream, int size);
30+
31+
[DllImport("libc", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
32+
public static extern IntPtr fdopen(int fd, string mode);
33+
}
34+
}

0 commit comments

Comments
 (0)