diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 9433cb2f5..6c46d89ef 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -33,7 +33,7 @@ parameters: variables: - name: 'GVFSMajorAndMinorVersion' - value: '2.0' + value: '2.1' - name: 'GVFSRevision' value: $(Build.BuildNumber) - name: 'GVFSVersion' diff --git a/GVFS/GVFS.Common/GVFSJsonContext.cs b/GVFS/GVFS.Common/GVFSJsonContext.cs index 1a203ee8a..7fca1d2be 100644 --- a/GVFS/GVFS.Common/GVFSJsonContext.cs +++ b/GVFS/GVFS.Common/GVFSJsonContext.cs @@ -38,6 +38,8 @@ namespace GVFS.Common [JsonSerializable(typeof(NamedPipeMessages.EnableAndAttachProjFSRequest.Response), TypeInfoPropertyName = "EnableAndAttachProjFSResponse")] [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest))] [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest.Response), TypeInfoPropertyName = "GetActiveRepoListResponse")] + [JsonSerializable(typeof(NamedPipeMessages.RunInstallerRequest))] + [JsonSerializable(typeof(NamedPipeMessages.RunInstallerRequest.Response), TypeInfoPropertyName = "RunInstallerResponse")] [JsonSerializable(typeof(NamedPipeMessages.BaseResponse))] [JsonSerializable(typeof(TelemetryDaemonEventListener.PipeMessage))] [JsonSerializable(typeof(PrettyConsoleEventListener.ConsoleOutputPayload))] diff --git a/GVFS/GVFS.Common/InstallerVerifier.cs b/GVFS/GVFS.Common/InstallerVerifier.cs new file mode 100644 index 000000000..0bf59d1a9 --- /dev/null +++ b/GVFS/GVFS.Common/InstallerVerifier.cs @@ -0,0 +1,403 @@ +using GVFS.Common.Tracing; +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace GVFS.Common +{ + /// + /// Verifies that an installer executable is a genuine VFS for Git installer + /// by checking its Authenticode signature and PE version info. + /// + public static class InstallerVerifier + { + public const string ExpectedProductName = "VFS for Git"; + public const string ExpectedSignerCommonName = "Microsoft Corporation"; + + /// + /// Verifies the installer at the given path. Returns true if the + /// installer passes all checks, false otherwise. + /// + /// + /// When true, skip Authenticode verification (for dev/test builds). + /// Product identity is still checked. + /// + public static bool TryVerifyInstaller( + ITracer tracer, + string installerPath, + bool allowUnsigned, + out string error) + { + ArgumentNullException.ThrowIfNull(tracer); + ArgumentNullException.ThrowIfNull(installerPath); + + if (!File.Exists(installerPath)) + { + error = $"Installer not found: {installerPath}"; + return false; + } + + // Always verify product identity, even when unsigned is allowed. + if (!TryVerifyProductIdentity(tracer, installerPath, out error)) + { + return false; + } + + if (allowUnsigned) + { + tracer.RelatedWarning( + $"{nameof(InstallerVerifier)}: Skipping Authenticode verification (--allow-unsigned)"); + error = null; + return true; + } + + if (!TryVerifyAuthenticodeSignature(tracer, installerPath, out error)) + { + return false; + } + + error = null; + return true; + } + + private static bool TryVerifyProductIdentity( + ITracer tracer, + string installerPath, + out string error) + { + FileVersionInfo versionInfo; + try + { + versionInfo = FileVersionInfo.GetVersionInfo(installerPath); + } + catch (Exception ex) + { + error = $"Failed to read version info from {installerPath}: {ex.Message}"; + tracer.RelatedError(error); + return false; + } + + string productName = versionInfo.ProductName?.Trim(); + if (!string.Equals(productName, ExpectedProductName, StringComparison.OrdinalIgnoreCase)) + { + error = $"Installer ProductName '{productName}' does not match expected '{ExpectedProductName}'"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + tracer.RelatedInfo( + $"{nameof(InstallerVerifier)}: Product identity verified — " + + $"ProductName='{productName}', FileVersion='{versionInfo.FileVersion?.Trim()}'"); + + error = null; + return true; + } + + private static bool TryVerifyAuthenticodeSignature( + ITracer tracer, + string installerPath, + out string error) + { + // Authenticode verification is Windows-only. Fail closed on + // other platforms — callers can opt in with --allow-unsigned + // when running on non-Windows for dev/test scenarios. + if (!OperatingSystem.IsWindows()) + { + error = "Authenticode verification is only supported on Windows"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + // Step 1: Verify the file's Authenticode signature with + // WinVerifyTrust and extract the leaf signer certificate from + // its provider state. This is the ONLY API that actually checks + // the signed digest against the file's contents — extracting + // the signer certificate by parsing the PE alone does NOT + // detect a tampered file with an intact signature blob. + if (!TryWinVerifyTrustAndGetSignerCert( + installerPath, + out byte[] signerCertBytes, + out string trustError)) + { + error = trustError; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + // Step 2: After WinVerifyTrust has confirmed the file's + // signature is intact, inspect the signer certificate to + // verify it really is Microsoft (and not just any valid + // code-signing cert). Use the modern X509CertificateLoader + // to materialize the cert from the DER bytes we copied out of + // the WinTrust provider state — X509Certificate.CreateFromSignedFile + // is obsolete (SYSLIB0057) and its replacement does not parse + // PE signature blocks. + X509Certificate2 certificate; + try + { + certificate = X509CertificateLoader.LoadCertificate(signerCertBytes); + } + catch (CryptographicException ex) + { + error = $"Failed to parse signer certificate: {ex.Message}"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + using (certificate) + { + // Exact CN match — GetNameInfo(SimpleName) parses the + // certificate's Subject DN and returns the raw CN value, + // avoiding substring-collision attacks such as + // CN="Microsoft Corporation Ltd" or DNs that put the + // attacker's name in the CN field with "Microsoft + // Corporation" appearing elsewhere. + string signerCommonName = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + if (!string.Equals(signerCommonName, ExpectedSignerCommonName, StringComparison.Ordinal)) + { + error = $"Installer signed by unexpected publisher: '{signerCommonName}' (Subject: {certificate.Subject})"; + tracer.RelatedError($"{nameof(InstallerVerifier)}: {error}"); + return false; + } + + tracer.RelatedInfo( + $"{nameof(InstallerVerifier)}: Authenticode signature verified — " + + $"Signer CN='{signerCommonName}', Thumbprint={certificate.Thumbprint}"); + } + + error = null; + return true; + } + + // WinVerifyTrust interop — verifies Authenticode hash + signature + // chain in one call. This is the same code path Windows uses for + // SmartScreen / SRP / WDAC, and is the ONLY supported way to + // verify Authenticode on Windows. See + // https://learn.microsoft.com/en-us/windows/win32/api/wintrust/nf-wintrust-winverifytrust. + + private static readonly Guid WINTRUST_ACTION_GENERIC_VERIFY_V2 = + new Guid("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); + + private const uint WTD_UI_NONE = 2; + private const uint WTD_REVOKE_WHOLECHAIN = 1; + private const uint WTD_CHOICE_FILE = 1; + private const uint WTD_STATEACTION_VERIFY = 1; + private const uint WTD_STATEACTION_CLOSE = 2; + + // S_OK and the few error codes we want to surface by name. + private const int S_OK = 0; + private const int TRUST_E_NOSIGNATURE = unchecked((int)0x800B0100); + private const int TRUST_E_BAD_DIGEST = unchecked((int)0x80096010); + private const int TRUST_E_PROVIDER_UNKNOWN = unchecked((int)0x800B0001); + private const int CRYPT_E_SECURITY_SETTINGS = unchecked((int)0x80092026); + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_FILE_INFO + { + public uint cbStruct; + [MarshalAs(UnmanagedType.LPWStr)] public string pcwszFilePath; + public IntPtr hFile; + public IntPtr pgKnownSubject; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WINTRUST_DATA + { + public uint cbStruct; + public IntPtr pPolicyCallbackData; + public IntPtr pSIPClientData; + public uint dwUIChoice; + public uint fdwRevocationChecks; + public uint dwUnionChoice; + public IntPtr pFile; + public uint dwStateAction; + public IntPtr hWVTStateData; + public IntPtr pwszURLReference; + public uint dwProvFlags; + public uint dwUIContext; + public IntPtr pSignatureSettings; + } + + [DllImport("wintrust.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = false)] + [SupportedOSPlatform("windows")] + private static extern int WinVerifyTrust(IntPtr hWnd, ref Guid pgActionID, ref WINTRUST_DATA pWVTData); + + // WTHelper interop — used to extract the signer certificate from a + // WinVerifyTrust state after a successful verification. Avoids the + // obsolete X509Certificate.CreateFromSignedFile (SYSLIB0057), and + // reuses the verification we already did rather than reparsing the + // PE signature block from scratch. + + [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false)] + [SupportedOSPlatform("windows")] + private static extern IntPtr WTHelperProvDataFromStateData(IntPtr hStateData); + + [DllImport("wintrust.dll", ExactSpelling = true, SetLastError = false)] + [SupportedOSPlatform("windows")] + private static extern IntPtr WTHelperGetProvSignerFromChain( + IntPtr pProvData, + uint idxSigner, + [MarshalAs(UnmanagedType.Bool)] bool fCounterSigner, + uint idxCounterSigner); + + // Partial layouts — we only marshal the fields we need. Sequential + // layout means the runtime computes offsets/padding correctly for + // the leading fields; we never read past the declared end, so the + // trailing fields can be omitted without risk. + + [StructLayout(LayoutKind.Sequential)] + private struct CRYPT_PROVIDER_SGNR + { + public uint cbStruct; + public System.Runtime.InteropServices.ComTypes.FILETIME sftVerifyAsOf; + public uint csCertChain; + public IntPtr pasCertChain; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CRYPT_PROVIDER_CERT + { + public uint cbStruct; + public IntPtr pCert; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CERT_CONTEXT + { + public uint dwCertEncodingType; + public IntPtr pbCertEncoded; + public uint cbCertEncoded; + public IntPtr pCertInfo; + public IntPtr hCertStore; + } + + [SupportedOSPlatform("windows")] + private static bool TryWinVerifyTrustAndGetSignerCert( + string filePath, + out byte[] signerCertBytes, + out string error) + { + signerCertBytes = null; + + WINTRUST_FILE_INFO fileInfo = new WINTRUST_FILE_INFO + { + cbStruct = (uint)Marshal.SizeOf(), + pcwszFilePath = filePath, + hFile = IntPtr.Zero, + pgKnownSubject = IntPtr.Zero, + }; + + IntPtr fileInfoPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr(fileInfo, fileInfoPtr, fDeleteOld: false); + + WINTRUST_DATA data = new WINTRUST_DATA + { + cbStruct = (uint)Marshal.SizeOf(), + pPolicyCallbackData = IntPtr.Zero, + pSIPClientData = IntPtr.Zero, + dwUIChoice = WTD_UI_NONE, + fdwRevocationChecks = WTD_REVOKE_WHOLECHAIN, + dwUnionChoice = WTD_CHOICE_FILE, + pFile = fileInfoPtr, + dwStateAction = WTD_STATEACTION_VERIFY, + hWVTStateData = IntPtr.Zero, + pwszURLReference = IntPtr.Zero, + dwProvFlags = 0, + dwUIContext = 0, + pSignatureSettings = IntPtr.Zero, + }; + + Guid action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + int verifyResult = WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + string extractError = null; + if (verifyResult == S_OK) + { + // Extract the leaf signer cert from WinTrust's provider + // state BEFORE closing the state (which would free the + // underlying CERT_CONTEXT). + extractError = TryExtractSignerCert(data.hWVTStateData, out signerCertBytes); + } + + // Always close the state to release wintrust resources. + data.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust(IntPtr.Zero, ref action, ref data); + + if (verifyResult == S_OK) + { + if (signerCertBytes == null) + { + error = extractError ?? "Failed to extract signer certificate from verified file"; + return false; + } + error = null; + return true; + } + + error = verifyResult switch + { + TRUST_E_NOSIGNATURE => "Installer is not signed", + TRUST_E_BAD_DIGEST => "Installer Authenticode hash does not match — file has been tampered with", + TRUST_E_PROVIDER_UNKNOWN => "Authenticode trust provider is not available", + CRYPT_E_SECURITY_SETTINGS => "Authenticode verification blocked by local security policy", + _ => $"Authenticode verification failed (HRESULT 0x{verifyResult:X8})", + }; + return false; + } + finally + { + Marshal.FreeHGlobal(fileInfoPtr); + } + } + + [SupportedOSPlatform("windows")] + private static string TryExtractSignerCert(IntPtr hWVTStateData, out byte[] certBytes) + { + certBytes = null; + + IntPtr provData = WTHelperProvDataFromStateData(hWVTStateData); + if (provData == IntPtr.Zero) + { + return "WTHelperProvDataFromStateData returned null"; + } + + IntPtr signerPtr = WTHelperGetProvSignerFromChain(provData, idxSigner: 0, fCounterSigner: false, idxCounterSigner: 0); + if (signerPtr == IntPtr.Zero) + { + return "WTHelperGetProvSignerFromChain returned null"; + } + + CRYPT_PROVIDER_SGNR signer = Marshal.PtrToStructure(signerPtr); + if (signer.csCertChain == 0 || signer.pasCertChain == IntPtr.Zero) + { + return "Signer has no certificate chain"; + } + + // pasCertChain[0] is the leaf signer cert by convention. + CRYPT_PROVIDER_CERT providerCert = Marshal.PtrToStructure(signer.pasCertChain); + if (providerCert.pCert == IntPtr.Zero) + { + return "Signer certificate context is null"; + } + + CERT_CONTEXT certContext = Marshal.PtrToStructure(providerCert.pCert); + if (certContext.pbCertEncoded == IntPtr.Zero || certContext.cbCertEncoded == 0) + { + return "Signer certificate has no encoded data"; + } + + // Copy the DER bytes out — they live in the WinTrust state which + // is about to be freed. + byte[] buffer = new byte[certContext.cbCertEncoded]; + Marshal.Copy(certContext.pbCertEncoded, buffer, 0, buffer.Length); + certBytes = buffer; + return null; + } + } +} diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index d42c84873..6ca984d5b 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -452,6 +452,41 @@ public static Response FromMessage(Message message) } } + public class RunInstallerRequest + { + public const string Header = nameof(RunInstallerRequest); + + /// + /// Full path to the installer executable. + /// + public string InstallerPath { get; set; } + + /// + /// When true, skip Authenticode signature verification. + /// Product identity (ProductName) is still checked. + /// Intended for development/test builds only. + /// + public bool AllowUnsigned { get; set; } + + public static RunInstallerRequest FromMessage(Message message) + { + return GVFSJsonOptions.Deserialize(message.Body); + } + + public Message ToMessage() + { + return new Message(Header, GVFSJsonOptions.Serialize(this)); + } + + public class Response : BaseResponse + { + public static Response FromMessage(Message message) + { + return GVFSJsonOptions.Deserialize(message.Body); + } + } + } + public class BaseResponse { public const string Header = nameof(TRequest) + ResponseSuffix; diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs index 40d2507be..54e1fed82 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeServer.cs @@ -203,6 +203,27 @@ public bool IsConnected get { return !this.isStopping() && this.serverStream.IsConnected; } } + /// + /// Impersonates the connected client for the duration of + /// . Inside the action, + /// + /// returns the client's identity rather than the service's. + /// Returns false if impersonation is not supported on this + /// platform (only Windows named pipes carry caller identity). + /// + public bool TryRunAsClient(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (!OperatingSystem.IsWindows()) + { + return false; + } + + this.serverStream.RunAsClient(() => action()); + return true; + } + public NamedPipeMessages.Message ReadMessage() { return NamedPipeMessages.Message.FromString(this.ReadRequest()); diff --git a/GVFS/GVFS.Service/Handlers/RequestHandler.cs b/GVFS/GVFS.Service/Handlers/RequestHandler.cs index 72e6e9e08..8a444242f 100644 --- a/GVFS/GVFS.Service/Handlers/RequestHandler.cs +++ b/GVFS/GVFS.Service/Handlers/RequestHandler.cs @@ -112,6 +112,13 @@ protected virtual void HandleMessage( this.TrySendResponse(tracer, response.ToMessage().ToString(), connection); break; + case NamedPipeMessages.RunInstallerRequest.Header: + this.requestDescription = "run installer"; + NamedPipeMessages.RunInstallerRequest installerRequest = NamedPipeMessages.RunInstallerRequest.FromMessage(message); + RunInstallerHandler installerHandler = new RunInstallerHandler(tracer, connection, installerRequest); + installerHandler.Run(); + break; + default: this.requestDescription = UnknownRequestDescription; EventMetadata metadata = new EventMetadata(); diff --git a/GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs b/GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs new file mode 100644 index 000000000..cb647572e --- /dev/null +++ b/GVFS/GVFS.Service/Handlers/RunInstallerHandler.cs @@ -0,0 +1,280 @@ +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.Common.Tracing; +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.Versioning; +using System.Security.Principal; + +namespace GVFS.Service.Handlers +{ + public class RunInstallerHandler + { + private const string InstallerArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=\"{0}\""; + + private readonly ITracer tracer; + private readonly NamedPipeServer.Connection connection; + private readonly NamedPipeMessages.RunInstallerRequest request; + + public RunInstallerHandler( + ITracer tracer, + NamedPipeServer.Connection connection, + NamedPipeMessages.RunInstallerRequest request) + { + this.tracer = tracer; + this.connection = connection; + this.request = request; + } + + public void Run() + { + NamedPipeMessages.RunInstallerRequest.Response response = + new NamedPipeMessages.RunInstallerRequest.Response(); + + try + { + string installerPath = this.request.InstallerPath; + if (string.IsNullOrWhiteSpace(installerPath)) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = "Installer path is required"; + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } + + // Resolve to full path to prevent path traversal. + installerPath = Path.GetFullPath(installerPath); + + // --allow-unsigned is a debug-build-only escape hatch and + // is rejected outright in release builds. Defense-in-depth + // against a malicious client crafting a raw pipe request + // bypassing the CLI's debug-only flag registration. +#if !DEBUG + if (this.request.AllowUnsigned) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = + "--allow-unsigned is not supported in release builds. " + + "Use a signed installer."; + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } +#else + // Debug builds still require the caller to be an + // Administrator when using --allow-unsigned: an unsigned + // installer is trivially attacker-controlled (any user can + // stamp ProductName="VFS for Git" onto a binary), and the + // pipe is open to BUILTIN\Users. Without this check, any + // non-admin user on a debug-build dev machine could get + // LocalSystem code execution through the service. + if (this.request.AllowUnsigned && !this.IsCallerAdministrator(out string identityError)) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = + "--allow-unsigned upgrades require Administrator privileges. " + + "Either use a signed installer or run 'gvfs upgrade --allow-unsigned' " + + "from an elevated shell."; + if (!string.IsNullOrEmpty(identityError)) + { + response.ErrorMessage += $" ({identityError})"; + } + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } +#endif + + // Hold a deny-write/delete handle on the installer for the + // lifetime of verify+launch to prevent a TOCTOU swap by a + // non-admin caller (FileShare.Read allows readers but + // blocks any write, delete, or rename of the path). + FileStream installerLock; + try + { + installerLock = new FileStream( + installerPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + } + catch (Exception ex) when (ex is FileNotFoundException + || ex is DirectoryNotFoundException + || ex is UnauthorizedAccessException + || ex is IOException) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = $"Failed to open installer for verification: {ex.Message}"; + this.tracer.RelatedError(this.CreateBaseMetadata(), response.ErrorMessage); + return; + } + + using (installerLock) + { + if (!InstallerVerifier.TryVerifyInstaller( + this.tracer, + installerPath, + this.request.AllowUnsigned, + out string verifyError)) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = verifyError; + return; + } + + string logPath = Path.Combine( + Configuration.AssemblyPath, + "ProgramData", + "upgrade-install.log"); + + this.tracer.RelatedInfo( + this.CreateBaseMetadata(), + $"{nameof(RunInstallerHandler)}: Verification passed, launching installer (log: {logPath})"); + + int exitCode = LaunchInstallerAndWait(installerPath, logPath); + + EventMetadata launchMetadata = this.CreateBaseMetadata(); + launchMetadata.Add("InstallerExitCode", exitCode); + + if (exitCode == 0) + { + response.State = NamedPipeMessages.CompletionState.Success; + this.tracer.RelatedInfo( + launchMetadata, + $"{nameof(RunInstallerHandler)}: Installer launched successfully"); + } + else + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = $"Installer failed to launch (error {exitCode})"; + this.tracer.RelatedError(launchMetadata, response.ErrorMessage); + } + } + } + catch (Exception ex) + { + response.State = NamedPipeMessages.CompletionState.Failure; + response.ErrorMessage = $"Failed to run installer: {ex.Message}"; + EventMetadata exceptionMetadata = this.CreateBaseMetadata(); + exceptionMetadata.Add("Exception", ex.ToString()); + this.tracer.RelatedError( + exceptionMetadata, + $"{nameof(RunInstallerHandler)}: {response.ErrorMessage}"); + } + finally + { + this.connection.TrySendResponse(response.ToMessage().ToString()); + } + } + + /// + /// Returns a fresh populated with the + /// request's identifying fields. A new instance must be used for each + /// tracer call because mutates the metadata + /// (adds the "Message" key); reusing the same instance across calls + /// triggers a duplicate-key exception. + /// + private EventMetadata CreateBaseMetadata() + { + EventMetadata metadata = new EventMetadata(); + metadata.Add("InstallerPath", this.request?.InstallerPath); + metadata.Add("AllowUnsigned", this.request?.AllowUnsigned ?? false); + return metadata; + } + + /// + /// Impersonates the pipe client and tests whether the caller is in + /// the local Administrators group. Returns false (and sets + /// ) if impersonation fails or the caller is + /// a regular user. Required because the service runs as LocalSystem + /// and the pipe is accessible to all interactive users — without + /// this check, any non-admin user could request privileged + /// operations. + /// + private bool IsCallerAdministrator(out string error) + { + // Named-pipe client impersonation is a Windows-only capability. + // RunInstallerHandler runs in GVFS.Service which is Windows-only + // in practice, but guard anyway so we fail closed. + if (!OperatingSystem.IsWindows()) + { + error = "Client identity check is not supported on this platform"; + return false; + } + + bool isAdmin = false; + string innerError = null; + + bool impersonated = this.connection.TryRunAsClient(() => + { + try + { + isAdmin = IsCurrentUserAdministrator(); + } + catch (Exception ex) + { + innerError = ex.Message; + } + }); + + if (!impersonated) + { + error = "Failed to impersonate pipe client"; + return false; + } + + if (innerError != null) + { + error = innerError; + return false; + } + + error = null; + return isAdmin; + } + + [SupportedOSPlatform("windows")] + private static bool IsCurrentUserAdministrator() + { + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) + { + WindowsPrincipal principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + } + + /// + /// Launches the installer as a detached process. The installer will + /// stop GVFS.Service as part of its upgrade flow, so we must not wait + /// for it to exit (that would deadlock — parent waiting on child that + /// kills parent). Returns 0 if the process started, or -1 on failure. + /// + private static int LaunchInstallerAndWait(string installerPath, string logPath) + { + string args = string.Format(InstallerArgs, logPath); + try + { + Process installerProcess = new Process(); + installerProcess.StartInfo = new ProcessStartInfo + { + FileName = installerPath, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true, + }; + + if (!installerProcess.Start()) + { + return -1; + } + + // Do NOT call WaitForExit(). The installer will stop + // GVFS.Service (our parent), so we'd deadlock. + return 0; + } + catch (Exception) + { + return -1; + } + } + } +} diff --git a/GVFS/GVFS/CommandLine/UpgradeVerb.cs b/GVFS/GVFS/CommandLine/UpgradeVerb.cs index 70f135d96..78ad5815f 100644 --- a/GVFS/GVFS/CommandLine/UpgradeVerb.cs +++ b/GVFS/GVFS/CommandLine/UpgradeVerb.cs @@ -1,5 +1,7 @@ using GVFS.Common; +using GVFS.Common.NamedPipes; using System; +using System.IO; namespace GVFS.CommandLine { @@ -12,24 +14,25 @@ public UpgradeVerb() this.Output = Console.Out; } - public bool Confirmed { get; set; } + public string InstallerPath { get; set; } - public bool DryRun { get; set; } - - public bool NoVerify { get; set; } + public bool AllowUnsigned { get; set; } public static System.CommandLine.Command CreateCommand() { - System.CommandLine.Command cmd = new System.CommandLine.Command("upgrade", "Checks for new GVFS release, downloads and installs it when available."); - - System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually install the newest release" }; - cmd.Add(confirmOption); + System.CommandLine.Command cmd = new System.CommandLine.Command("upgrade", "Upgrade VFS for Git by running an installer through the GVFS service (no UAC required)."); - System.CommandLine.Option dryRunOption = new System.CommandLine.Option("--dry-run") { Description = "Display progress and errors, but don't install GVFS" }; - cmd.Add(dryRunOption); + System.CommandLine.Argument installerPathArg = new System.CommandLine.Argument("installer-path") + { + Description = "Path to the SetupGVFS.*.exe installer", + }; + cmd.Add(installerPathArg); - System.CommandLine.Option noVerifyOption = new System.CommandLine.Option("--no-verify") { Description = "Do not verify NuGet packages after downloading them. Some platforms do not support NuGet verification." }; - cmd.Add(noVerifyOption); +#if DEBUG + System.CommandLine.Option allowUnsignedOption = new System.CommandLine.Option( + "--allow-unsigned") { Description = "Skip Authenticode signature verification (debug builds only, requires admin)" }; + cmd.Add(allowUnsignedOption); +#endif System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); cmd.Add(internalOption); @@ -37,9 +40,10 @@ public static System.CommandLine.Command CreateCommand() GVFSVerb.SetActionForNoEnlistment(cmd, internalOption, (verb, result) => { - verb.Confirmed = result.GetValue(confirmOption); - verb.DryRun = result.GetValue(dryRunOption); - verb.NoVerify = result.GetValue(noVerifyOption); + verb.InstallerPath = result.GetValue(installerPathArg); +#if DEBUG + verb.AllowUnsigned = result.GetValue(allowUnsignedOption); +#endif }); return cmd; @@ -52,7 +56,82 @@ protected override string VerbName public override void Execute() { - Console.Error.WriteLine("'gvfs upgrade' is no longer supported. Visit https://github.com/microsoft/vfsforgit for the latest install/upgrade instructions."); + if (string.IsNullOrWhiteSpace(this.InstallerPath)) + { + this.ReportErrorAndExit("Installer path is required. Usage: gvfs upgrade "); + return; + } + + string fullPath = Path.GetFullPath(this.InstallerPath); + if (!File.Exists(fullPath)) + { + this.ReportErrorAndExit($"Installer not found: {fullPath}"); + return; + } + + this.Output.WriteLine($"Requesting upgrade via GVFS service..."); + this.Output.WriteLine($"Installer: {fullPath}"); + + if (this.AllowUnsigned) + { + this.Output.WriteLine("WARNING: Authenticode signature verification is disabled (--allow-unsigned)"); + } + + NamedPipeMessages.RunInstallerRequest request = new NamedPipeMessages.RunInstallerRequest + { + InstallerPath = fullPath, + AllowUnsigned = this.AllowUnsigned, + }; + + using (NamedPipeClient client = new NamedPipeClient(this.ServicePipeName)) + { + if (!client.Connect()) + { + this.ReportErrorAndExit( + "Unable to connect to GVFS service. Is GVFS.Service running?"); + return; + } + + try + { + client.SendRequest(request.ToMessage()); + NamedPipeMessages.Message rawResponse = client.ReadResponse(); + + // Old GVFS.Service (pre-2.1) doesn't know about + // RunInstallerRequest and returns the literal + // "UnknownRequest" sentinel with no body. Detect that + // case explicitly so the user gets a clear "service is + // too old" message instead of a JSON deserialization + // stack trace. + if (string.Equals(rawResponse.Header, NamedPipeMessages.UnknownRequest, StringComparison.Ordinal)) + { + this.ReportErrorAndExit( + "The installed GVFS service does not support 'gvfs upgrade'. " + + "This feature requires GVFS.Service 2.1 or later — please install a " + + "newer GVFS using the standard installer first, then retry."); + return; + } + + NamedPipeMessages.RunInstallerRequest.Response response = + NamedPipeMessages.RunInstallerRequest.Response.FromMessage(rawResponse); + + if (response.State == NamedPipeMessages.CompletionState.Success) + { + this.Output.WriteLine("Upgrade started. The installer is running in the background."); + this.Output.WriteLine("GVFS service will restart automatically. Check 'gvfs version' after a few seconds."); + } + else + { + this.ReportErrorAndExit( + $"Upgrade failed: {response.ErrorMessage}"); + } + } + catch (BrokenPipeException ex) + { + this.ReportErrorAndExit( + $"Lost connection to GVFS service during upgrade: {ex.Message}"); + } + } } } }