Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions GVFS/GVFS.Common/GVFSPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ public static void Register(GVFSPlatform platform)
/// </exception>
public abstract void PrepareProcessToRunInBackground();

/// <summary>
/// Returns true if the current process owns a visible console window that a child process
/// (e.g. git launching the credential manager) can use as the owner for an interactive prompt.
/// </summary>
/// <remarks>
/// This inspects the current process's console window and its root owner, not arbitrary GUI
/// windows. Background processes such as GVFS.Mount run with a hidden console, so this returns
/// false for them; foreground processes launched from a visible console return true. Credential
/// helpers launched by a process with no visible console window should be started detached from
/// the console, so the credential manager parents its prompt to a topmost stub window instead
/// of the hidden console.
/// </remarks>
public abstract bool CurrentProcessHasVisibleConsoleWindow();

public abstract bool IsProcessActive(int processId);

/// <summary>
Expand Down
74 changes: 65 additions & 9 deletions GVFS/GVFS.Common/Git/GitProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,18 +273,27 @@ public virtual bool TryGetCertificatePassword(

using (ITracer activity = tracer.StartActivity("TryGetCertificatePassword", EventLevel.Informational))
{
// Record whether the current process has a visible console window, using the same value
// GetGitProcess uses to decide whether to detach git from a hidden console. Capturing it
// once here keeps the logged value authoritative and diagnosable on both the success and
// failure paths when a credential prompt fails to appear.
bool hasVisibleConsoleWindow = CurrentProcessHasVisibleConsoleWindowSafe();

// See GetFromConfig for why pre-command hook is disabled
// for bootstrap-time git operations.
Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder(
"credential fill",
stdin => stdin.Write("protocol=cert\npath=" + certificatePath + "\nusername=\n\n"),
parseStdOutLine: null,
usePreCommandHook: false);
usePreCommandHook: false,
mayRequireAuth: true,
hasVisibleConsoleWindow: hasVisibleConsoleWindow);

if (gitCredentialOutput.ExitCodeIsFailure)
{
EventMetadata errorData = new EventMetadata();
errorData.Add("CertificatePath", certificatePath);
errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow);
tracer.RelatedWarning(
errorData,
"Git could not get credentials: " + gitCredentialOutput.Errors,
Expand All @@ -301,7 +310,8 @@ public virtual bool TryGetCertificatePassword(
EventMetadata metadata = new EventMetadata
{
{ "Success", success },
{ "CertificatePath", certificatePath }
{ "CertificatePath", certificatePath },
{ "hasVisibleConsoleWindow", hasVisibleConsoleWindow }
};

if (!success)
Expand All @@ -328,18 +338,27 @@ public virtual bool TryGetCredential(

using (ITracer activity = tracer.StartActivity(nameof(this.TryGetCredential), EventLevel.Informational))
{
// Record whether the current process has a visible console window, using the same value
// GetGitProcess uses to decide whether to detach git from a hidden console. Capturing it
// once here keeps the logged value authoritative and diagnosable on both the success and
// failure paths (including the timeout that a hidden, unanswered prompt produces).
bool hasVisibleConsoleWindow = CurrentProcessHasVisibleConsoleWindowSafe();

// See GetFromConfig for why pre-command hook is disabled
// for bootstrap-time git operations.
Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder(
GenerateCredentialVerbCommand("fill"),
stdin => stdin.Write($"url={repoUrl}\n\n"),
parseStdOutLine: null,
usePreCommandHook: false,
timeoutMs: timeoutMs);
timeoutMs: timeoutMs,
mayRequireAuth: true,
hasVisibleConsoleWindow: hasVisibleConsoleWindow);

if (gitCredentialOutput.ExitCodeIsFailure)
{
EventMetadata errorData = new EventMetadata();
errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow);

if (gitCredentialOutput.Errors.StartsWith("Operation timed out"))
{
Expand All @@ -361,6 +380,14 @@ public virtual bool TryGetCredential(
return false;
}

// Record whether the current process had a visible console window. GetGitProcess uses
// this to decide whether to detach git from a hidden console so the credential prompt is
// not left behind other windows; logging it keeps that decision diagnosable from telemetry
// when a prompt fails to appear.
EventMetadata consoleMetadata = new EventMetadata();
consoleMetadata.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow);
activity.RelatedInfo(consoleMetadata, "Credential fill console window visibility");
Comment thread
tyrielv marked this conversation as resolved.

username = ParseValue(gitCredentialOutput.Output, "username=");
password = ParseValue(gitCredentialOutput.Output, "password=");

Expand Down Expand Up @@ -902,7 +929,7 @@ public Result MultiPackIndexRepack(string gitObjectDirectory, string batchSize)
return this.InvokeGitAgainstDotGitFolder($"-c pack.threads=1 -c repack.packKeptObjects=true multi-pack-index repack --object-dir=\"{gitObjectDirectory}\" --batch-size={batchSize} --no-progress");
}

public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, string gitObjectsDirectory, bool usePreCommandHook)
public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, string gitObjectsDirectory, bool usePreCommandHook, bool mayRequireAuth = false, bool? hasVisibleConsoleWindow = null)
{
ProcessStartInfo processInfo = new ProcessStartInfo(this.gitBinPath);
processInfo.WorkingDirectory = workingDirectory;
Expand All @@ -919,7 +946,16 @@ public Process GetGitProcess(string command, string workingDirectory, string dot
// instead), CreateNoWindow must be set to true for that case — otherwise
// the non-redirected stream inherits the parent's console handle, which
// may be absent when running as a service, causing lost output.
processInfo.CreateNoWindow = false;
//
// When a command may require authentication, git can launch the credential
// manager. Detaching from a hidden console (CreateNoWindow=true) makes that
// prompt appear on top instead of behind other windows. See
// GVFSPlatform.CurrentProcessHasVisibleConsoleWindow for the full rationale.
// A caller that already probed the console visibility passes the result in
// hasVisibleConsoleWindow so the logged and acted-on value stay identical;
// otherwise probe here. The && short-circuits the probe when auth cannot
// be required.
processInfo.CreateNoWindow = mayRequireAuth && !(hasVisibleConsoleWindow ?? CurrentProcessHasVisibleConsoleWindowSafe());

processInfo.StandardOutputEncoding = UTF8NoBOM;
processInfo.StandardErrorEncoding = UTF8NoBOM;
Expand Down Expand Up @@ -986,7 +1022,9 @@ protected virtual Result InvokeGitImpl(
Action<string> parseStdOutLine,
int timeoutMs,
string gitObjectsDirectory = null,
bool usePreCommandHook = true)
bool usePreCommandHook = true,
bool mayRequireAuth = false,
bool? hasVisibleConsoleWindow = null)
{
if (failedToSetEncoding && writeStdIn != null)
{
Expand All @@ -998,7 +1036,7 @@ protected virtual Result InvokeGitImpl(
// From https://msdn.microsoft.com/en-us/library/system.diagnostics.process.standardoutput.aspx
// To avoid deadlocks, use asynchronous read operations on at least one of the streams.
// Do not perform a synchronous read to the end of both redirected streams.
using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook))
using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook, mayRequireAuth: mayRequireAuth, hasVisibleConsoleWindow: hasVisibleConsoleWindow))
{
// Bound how much stdout/stderr we buffer so a pathologically noisy git command
// cannot grow these buffers without limit until GVFS.Mount hits an
Expand Down Expand Up @@ -1086,6 +1124,20 @@ private static string GenerateCredentialVerbCommand(string verb)
return $"-c {GitConfigSetting.CredentialUseHttpPath}=true credential {verb}";
}

private static bool CurrentProcessHasVisibleConsoleWindowSafe()
{
try
{
return GVFSPlatform.Instance.CurrentProcessHasVisibleConsoleWindow();
}
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException)
{
// If the native console probe cannot run, assume a visible console so callers keep
// the previous behavior (CreateNoWindow=false) rather than aborting the command.
return true;
}
}

private static string ParseValue(string contents, string prefix)
{
int startIndex = contents.IndexOf(prefix) + prefix.Length;
Expand Down Expand Up @@ -1166,7 +1218,9 @@ private Result InvokeGitAgainstDotGitFolder(
Action<string> parseStdOutLine,
bool usePreCommandHook = true,
string gitObjectsDirectory = null,
int timeoutMs = -1)
int timeoutMs = -1,
bool mayRequireAuth = false,
bool? hasVisibleConsoleWindow = null)
{
// This git command should not need/use the working directory of the repo.
// Run git.exe in Environment.SystemDirectory to ensure the git.exe process
Expand All @@ -1180,7 +1234,9 @@ private Result InvokeGitAgainstDotGitFolder(
parseStdOutLine: parseStdOutLine,
timeoutMs: timeoutMs,
gitObjectsDirectory: gitObjectsDirectory,
usePreCommandHook: usePreCommandHook);
usePreCommandHook: usePreCommandHook,
mayRequireAuth: mayRequireAuth,
hasVisibleConsoleWindow: hasVisibleConsoleWindow);
}

public class Result
Expand Down
30 changes: 30 additions & 0 deletions GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.ServiceProcess;
Expand All @@ -28,6 +29,9 @@ public partial class WindowsPlatform : GVFSPlatform
private const string ProfileImagePathRegistryValue = "ProfileImagePath";
private const string AdminProtectionProfilePrefix = "ADMIN_";

// GetAncestor flag GA_ROOTOWNER (3): walk the owner chain to the root owned window.
private const uint GA_ROOTOWNER = 3;

public WindowsPlatform() : base(underConstruction: new UnderConstructionFlags())
{
}
Expand Down Expand Up @@ -224,6 +228,22 @@ public override void PrepareProcessToRunInBackground()
// No additional work required
}

public override bool CurrentProcessHasVisibleConsoleWindow()
{
IntPtr consoleWindow = GetConsoleWindow();
if (consoleWindow == IntPtr.Zero)
{
// The process has no console at all (e.g. a GUI process), so there is no console
// window to own an interactive prompt.
return false;
}

// GVFS.Mount runs with a hidden console, so its console window exists but its root
// owner is not visible. Only treat the window as usable when its root owner is visible.
IntPtr rootOwner = GetAncestor(consoleWindow, GA_ROOTOWNER);
return rootOwner != IntPtr.Zero && IsWindowVisible(rootOwner);
}

public override NamedPipeServerStream CreatePipeByName(string pipeName)
{
PipeSecurity security = new PipeSecurity();
Expand Down Expand Up @@ -458,6 +478,16 @@ private static object GetValueFromRegistry(RegistryHive registryHive, string key
return value;
}

[DllImport("kernel32.dll")]
private static extern IntPtr GetConsoleWindow();

[DllImport("user32.dll")]
private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags);

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsWindowVisible(IntPtr hWnd);

public class WindowsPlatformConstants : GVFSPlatformConstants
{
public override string ExecutableExtension
Expand Down
Loading
Loading