Skip to content

Commit aa53868

Browse files
authored
Better browser/interactive session detection inside of WSL (#1148)
Today we're gating browser-based authentication mechanisms behind a GUI-possible session, which isn't 100% correct. When inside of a WSL session, without WSLg enabled, it is still possible to launch a web browser in the hosting Windows session. Split the determination between "GUI session" and "browser possible", and add the special case for WSL inside an interactive Windows session. Things are complicated here because it is possible to SSH in to the WSL instance from outside of the Windows host, meaning the Windows session also doesn't have UI. This case we still need to prevent browser-based options. In order to detect these cases we need to 'punch out' to the Windows host and run a script/cmdlet to determine the session type. Fixes #878
2 parents 9f1b048 + d0b767b commit aa53868

22 files changed

+809
-67
lines changed

src/shared/Core.Tests/IniFileTests.cs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using System.Collections.Generic;
2+
using System.Text;
3+
using GitCredentialManager.Tests.Objects;
4+
using Xunit;
5+
6+
namespace GitCredentialManager.Tests
7+
{
8+
public class IniFileTests
9+
{
10+
[Fact]
11+
public void IniSectionName_Equality()
12+
{
13+
var a1 = new IniSectionName("foo");
14+
var b1 = new IniSectionName("foo");
15+
Assert.Equal(a1,b1);
16+
Assert.Equal(a1.GetHashCode(),b1.GetHashCode());
17+
18+
var a2 = new IniSectionName("foo");
19+
var b2 = new IniSectionName("FOO");
20+
Assert.Equal(a2,b2);
21+
Assert.Equal(a2.GetHashCode(),b2.GetHashCode());
22+
23+
var a3 = new IniSectionName("foo", "bar");
24+
var b3 = new IniSectionName("foo", "BAR");
25+
Assert.NotEqual(a3,b3);
26+
Assert.NotEqual(a3.GetHashCode(),b3.GetHashCode());
27+
28+
var a4 = new IniSectionName("foo", "bar");
29+
var b4 = new IniSectionName("FOO", "bar");
30+
Assert.Equal(a4,b4);
31+
Assert.Equal(a4.GetHashCode(),b4.GetHashCode());
32+
}
33+
34+
[Fact]
35+
public void IniSerializer_Deserialize()
36+
{
37+
const string path = "/tmp/test.ini";
38+
string iniText = @"
39+
[one]
40+
foo = 123
41+
[two]
42+
foo = abc
43+
# comment
44+
[two ""subsection name""] # comment [section]
45+
foo = this is different # comment prop = val
46+
47+
#[notasection]
48+
49+
[
50+
[bad #section]
51+
recovery tests]
52+
[]
53+
]
54+
55+
[three]
56+
bar = a
57+
bar = b
58+
# comment
59+
bar = c
60+
empty =
61+
[TWO]
62+
foo = hello
63+
widget = ""Hello, World!""
64+
[four]
65+
[five]
66+
prop1 = ""this hash # is inside quotes""
67+
prop2 = ""this hash # is inside quotes"" # this line has two hashes
68+
prop3 = "" this dquoted string has three spaces around ""
69+
#prop4 = this property has been commented-out
70+
";
71+
72+
var fs = new TestFileSystem
73+
{
74+
Files = { [path] = Encoding.UTF8.GetBytes(iniText) }
75+
};
76+
77+
IniFile ini = IniSerializer.Deserialize(fs, path);
78+
79+
Assert.Equal(6, ini.Sections.Count);
80+
81+
AssertSection(ini, "one", out IniSection one);
82+
Assert.Equal(1, one.Properties.Count);
83+
AssertProperty(one, "foo", "123");
84+
85+
AssertSection(ini, "two", out IniSection twoA);
86+
Assert.Equal(3, twoA.Properties.Count);
87+
AssertProperty(twoA, "foo", "hello");
88+
AssertProperty(twoA, "widget", "Hello, World!");
89+
90+
AssertSection(ini, "two", "subsection name", out IniSection twoB);
91+
Assert.Equal(1, twoB.Properties.Count);
92+
AssertProperty(twoB, "foo", "this is different");
93+
94+
AssertSection(ini, "three", out IniSection three);
95+
Assert.Equal(4, three.Properties.Count);
96+
AssertMultiProperty(three, "bar", "a", "b", "c");
97+
AssertProperty(three, "empty", "");
98+
99+
AssertSection(ini, "four", out IniSection four);
100+
Assert.Equal(0, four.Properties.Count);
101+
102+
AssertSection(ini, "five", out IniSection five);
103+
Assert.Equal(3, five.Properties.Count);
104+
AssertProperty(five, "prop1", "this hash # is inside quotes");
105+
AssertProperty(five, "prop2", "this hash # is inside quotes");
106+
AssertProperty(five, "prop3", " this dquoted string has three spaces around ");
107+
}
108+
109+
private static void AssertSection(IniFile file, string name, out IniSection section)
110+
{
111+
Assert.True(file.TryGetSection(name, out section));
112+
Assert.Equal(name, section.Name.Name);
113+
Assert.Null(section.Name.SubName);
114+
}
115+
116+
private static void AssertSection(IniFile file, string name, string subName, out IniSection section)
117+
{
118+
Assert.True(file.TryGetSection(name, subName, out section));
119+
Assert.Equal(name, section.Name.Name);
120+
Assert.Equal(subName, section.Name.SubName);
121+
}
122+
123+
private static void AssertProperty(IniSection section, string name, string value)
124+
{
125+
Assert.True(section.TryGetProperty(name, out var actualValue));
126+
Assert.Equal(value, actualValue);
127+
}
128+
129+
private static void AssertMultiProperty(IniSection section, string name, params string[] values)
130+
{
131+
Assert.True(section.TryGetMultiProperty(name, out IEnumerable<string> actualValues));
132+
Assert.Equal(values, actualValues);
133+
}
134+
}
135+
}

src/shared/Core/Authentication/MicrosoftAuthentication.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -508,14 +508,14 @@ private void EnsureCanUseEmbeddedWebView()
508508
private bool CanUseSystemWebView(IPublicClientApplication app, Uri redirectUri)
509509
{
510510
// MSAL requires the application redirect URI is a loopback address to use the System WebView
511-
return Context.SessionManager.IsDesktopSession && app.IsSystemWebViewAvailable && redirectUri.IsLoopback;
511+
return Context.SessionManager.IsWebBrowserAvailable && app.IsSystemWebViewAvailable && redirectUri.IsLoopback;
512512
}
513513

514514
private void EnsureCanUseSystemWebView(IPublicClientApplication app, Uri redirectUri)
515515
{
516-
if (!Context.SessionManager.IsDesktopSession)
516+
if (!Context.SessionManager.IsWebBrowserAvailable)
517517
{
518-
throw new InvalidOperationException("System web view is not available without a desktop session.");
518+
throw new InvalidOperationException("System web view is not available without a way to start a browser.");
519519
}
520520

521521
if (!app.IsSystemWebViewAvailable)

src/shared/Core/BrowserUtils.cs

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ public static void OpenDefaultBrowser(IEnvironment environment, Uri uri)
2525

2626
string url = uri.ToString();
2727

28-
ProcessStartInfo psi = null;
28+
ProcessStartInfo psi;
2929
if (PlatformUtils.IsLinux())
3030
{
31+
//
3132
// On Linux, 'shell execute' utilities like xdg-open launch a process without
3233
// detaching from the standard in/out descriptors. Some applications (like
3334
// Chromium) write messages to stdout, which is currently hooked up and being
@@ -40,29 +41,17 @@ public static void OpenDefaultBrowser(IEnvironment environment, Uri uri)
4041
// We try and use the same 'shell execute' utilities as the Framework does,
4142
// searching for them in the same order until we find one.
4243
//
43-
// One additional 'shell execute' utility we also attempt to use is `wslview`
44-
// that is commonly found on WSL (Windows Subsystem for Linux) distributions that
45-
// opens the browser on the Windows host.
46-
foreach (string shellExec in new[] {"xdg-open", "gnome-open", "kfmclient", "wslview"})
44+
if (!TryGetLinuxShellExecuteHandler(environment, out string shellExecPath))
4745
{
48-
if (environment.TryLocateExecutable(shellExec, out string shellExecPath))
49-
{
50-
psi = new ProcessStartInfo(shellExecPath, url)
51-
{
52-
RedirectStandardOutput = true,
53-
// Ok to redirect stderr for non-git-related processes
54-
RedirectStandardError = true
55-
};
56-
57-
// We found a way to open the URI; stop searching!
58-
break;
59-
}
46+
throw new Exception("Failed to locate a utility to launch the default web browser.");
6047
}
6148

62-
if (psi is null)
49+
psi = new ProcessStartInfo(shellExecPath, url)
6350
{
64-
throw new Exception("Failed to locate a utility to launch the default web browser.");
65-
}
51+
RedirectStandardOutput = true,
52+
// Ok to redirect stderr for non-git-related processes
53+
RedirectStandardError = true
54+
};
6655
}
6756
else
6857
{
@@ -77,5 +66,23 @@ public static void OpenDefaultBrowser(IEnvironment environment, Uri uri)
7766
// is no need to add the extra overhead associated with ChildProcess here.
7867
Process.Start(psi);
7968
}
69+
70+
public static bool TryGetLinuxShellExecuteHandler(IEnvironment env, out string shellExecPath)
71+
{
72+
// One additional 'shell execute' utility we also attempt to use over the Framework
73+
// is `wslview` that is commonly found on WSL (Windows Subsystem for Linux) distributions
74+
// that opens the browser on the Windows host.
75+
string[] shellHandlers = { "xdg-open", "gnome-open", "kfmclient", WslUtils.WslViewShellHandlerName };
76+
foreach (string shellExec in shellHandlers)
77+
{
78+
if (env.TryLocateExecutable(shellExec, out shellExecPath))
79+
{
80+
return true;
81+
}
82+
}
83+
84+
shellExecPath = null;
85+
return false;
86+
}
8087
}
8188
}

src/shared/Core/CommandContext.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ public CommandContext()
102102
if (PlatformUtils.IsWindows())
103103
{
104104
FileSystem = new WindowsFileSystem();
105-
SessionManager = new WindowsSessionManager();
106105
Environment = new WindowsEnvironment(FileSystem);
106+
SessionManager = new WindowsSessionManager(Environment, FileSystem);
107107
ProcessManager = new WindowsProcessManager(Trace2);
108108
Terminal = new WindowsTerminal(Trace);
109109
string gitPath = GetGitPath(Environment, FileSystem, Trace);
@@ -118,7 +118,7 @@ public CommandContext()
118118
else if (PlatformUtils.IsMacOS())
119119
{
120120
FileSystem = new MacOSFileSystem();
121-
SessionManager = new MacOSSessionManager();
121+
SessionManager = new MacOSSessionManager(Environment, FileSystem);
122122
Environment = new MacOSEnvironment(FileSystem);
123123
ProcessManager = new ProcessManager(Trace2);
124124
Terminal = new MacOSTerminal(Trace);
@@ -134,8 +134,7 @@ public CommandContext()
134134
else if (PlatformUtils.IsLinux())
135135
{
136136
FileSystem = new LinuxFileSystem();
137-
// TODO: support more than just 'Posix' or X11
138-
SessionManager = new PosixSessionManager();
137+
SessionManager = new LinuxSessionManager(Environment, FileSystem);
139138
Environment = new PosixEnvironment(FileSystem);
140139
ProcessManager = new ProcessManager(Trace2);
141140
Terminal = new LinuxTerminal(Trace);

src/shared/Core/EnvironmentBase.cs

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,45 @@ public interface IEnvironment
5353
/// <param name="target">Target level of environment variable to set (Machine, Process, or User).</param>
5454
void SetEnvironmentVariable(string variable, string value,
5555
EnvironmentVariableTarget target = EnvironmentVariableTarget.Process);
56+
57+
/// <summary>
58+
/// Refresh the current process environment variables. See <see cref="Variables"/>.
59+
/// </summary>
60+
/// <remarks>This is automatically called after <see cref="SetEnvironmentVariable"/>.</remarks>
61+
void Refresh();
5662
}
5763

5864
public abstract class EnvironmentBase : IEnvironment
5965
{
66+
private IReadOnlyDictionary<string, string> _variables;
67+
6068
protected EnvironmentBase(IFileSystem fileSystem)
6169
{
6270
EnsureArgument.NotNull(fileSystem, nameof(fileSystem));
63-
6471
FileSystem = fileSystem;
6572
}
6673

67-
public IReadOnlyDictionary<string, string> Variables { get; protected set; }
74+
internal EnvironmentBase(IFileSystem fileSystem, IReadOnlyDictionary<string, string> variables)
75+
: this(fileSystem)
76+
{
77+
EnsureArgument.NotNull(variables, nameof(variables));
78+
_variables = variables;
79+
}
80+
81+
public IReadOnlyDictionary<string, string> Variables
82+
{
83+
get
84+
{
85+
// Variables are lazily loaded
86+
if (_variables is null)
87+
{
88+
Refresh();
89+
}
90+
91+
Debug.Assert(_variables != null);
92+
return _variables;
93+
}
94+
}
6895

6996
protected IFileSystem FileSystem { get; }
7097

@@ -126,9 +153,22 @@ internal virtual bool TryLocateExecutable(string program, ICollection<string> pa
126153
public void SetEnvironmentVariable(string variable, string value,
127154
EnvironmentVariableTarget target = EnvironmentVariableTarget.Process)
128155
{
129-
if (Variables.Keys.Contains(variable)) return;
156+
// Don't bother setting the variable if it already has the same value
157+
if (Variables.TryGetValue(variable, out var currentValue) &&
158+
StringComparer.Ordinal.Equals(currentValue, value))
159+
{
160+
return;
161+
}
162+
130163
Environment.SetEnvironmentVariable(variable, value, target);
131-
Variables = GetCurrentVariables();
164+
165+
// Immediately refresh the variables so that the new value is available to callers using IEnvironment
166+
Refresh();
167+
}
168+
169+
public void Refresh()
170+
{
171+
_variables = GetCurrentVariables();
132172
}
133173

134174
protected abstract IReadOnlyDictionary<string, string> GetCurrentVariables();
@@ -151,5 +191,18 @@ public static string LocateExecutable(this IEnvironment environment, string prog
151191

152192
throw new Exception($"Failed to locate '{program}' executable on the path.");
153193
}
194+
195+
/// <summary>
196+
/// Retrieves the value of an environment variable from the current process.
197+
/// </summary>
198+
/// <param name="environment">The <see cref="IEnvironment"/>.</param>
199+
/// <param name="variable">The name of the environment variable.</param>
200+
/// <returns>
201+
/// The value of the environment variable specified by variable, or null if the environment variable is not found.
202+
/// </returns>
203+
public static string GetEnvironmentVariable(this IEnvironment environment, string variable)
204+
{
205+
return environment.Variables.TryGetValue(variable, out string value) ? value : null;
206+
}
154207
}
155208
}

src/shared/Core/FileSystem.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,29 @@ public interface IFileSystem
8484
/// </returns>
8585
IEnumerable<string> EnumerateFiles(string path, string searchPattern);
8686

87+
/// <summary>
88+
/// Returns an enumerable collection of directory full names in a specified path.
89+
/// </summary>
90+
/// <param name="path">The relative or absolute path to the directory to search. This string is not case-sensitive.</param>
91+
/// <returns>
92+
/// An enumerable collection of the full names (including paths) for the directories
93+
/// in the directory specified by path.
94+
/// </returns>
95+
IEnumerable<string> EnumerateDirectories(string path);
96+
97+
/// <summary>
98+
/// Opens a text file, reads all the text in the file, and then closes the file
99+
/// </summary>
100+
/// <param name="path">The file to open for reading.</param>
101+
/// <returns>A string containing all the text in the file.</returns>
102+
string ReadAllText(string path);
103+
104+
/// <summary>
105+
/// Opens a text file, reads all lines of the file, and then closes the file.
106+
/// </summary>
107+
/// <param name="path">The file to open for reading.</param>
108+
/// <returns>A string array containing all lines of the file.</returns>
109+
string[] ReadAllLines(string path);
87110
}
88111

89112
/// <summary>
@@ -111,5 +134,11 @@ public Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAcce
111134
public void DeleteFile(string path) => File.Delete(path);
112135

113136
public IEnumerable<string> EnumerateFiles(string path, string searchPattern) => Directory.EnumerateFiles(path, searchPattern);
137+
138+
public IEnumerable<string> EnumerateDirectories(string path) => Directory.EnumerateDirectories(path);
139+
140+
public string ReadAllText(string path) => File.ReadAllText(path);
141+
142+
public string[] ReadAllLines(string path) => File.ReadAllLines(path);
114143
}
115144
}

0 commit comments

Comments
 (0)