mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-30 16:07:29 +01:00
Compare commits
6 Commits
user/yeela
...
leilzh/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38c1aedc1b | ||
|
|
fca6d67a2e | ||
|
|
64cabc8789 | ||
|
|
989e005500 | ||
|
|
5f124cec55 | ||
|
|
8ec530c65e |
@@ -565,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+
|
||||
regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\)
|
||||
|
||||
# regex choice
|
||||
\(\?:[^)]+\|[^)]+\)
|
||||
# \(\?:[^)]+\|[^)]+\)
|
||||
|
||||
# proto
|
||||
^\s*(\w+)\s\g{-1} =
|
||||
|
||||
2
.github/actions/spell-check/excludes.txt
vendored
2
.github/actions/spell-check/excludes.txt
vendored
@@ -106,6 +106,8 @@
|
||||
^src/common/sysinternals/Eula/
|
||||
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
|
||||
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
|
||||
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$
|
||||
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
|
||||
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
|
||||
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$
|
||||
|
||||
4
.github/actions/spell-check/expect.txt
vendored
4
.github/actions/spell-check/expect.txt
vendored
@@ -597,6 +597,7 @@ frm
|
||||
FROMTOUCH
|
||||
fsanitize
|
||||
fsmgmt
|
||||
ftps
|
||||
fuzzingtesting
|
||||
fxf
|
||||
FZE
|
||||
@@ -1329,7 +1330,7 @@ phwnd
|
||||
pici
|
||||
pidl
|
||||
PIDLIST
|
||||
PII
|
||||
pii
|
||||
pinfo
|
||||
pinvoke
|
||||
pipename
|
||||
@@ -1715,6 +1716,7 @@ srw
|
||||
srwlock
|
||||
sse
|
||||
ssf
|
||||
Ssn
|
||||
sszzz
|
||||
STACKFRAME
|
||||
stackoverflow
|
||||
|
||||
@@ -300,6 +300,10 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/Tests/">
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<Custom Action="UnRegisterCmdPalPackage" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallCommandNotFound" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UpgradeCommandNotFound" After="InstallFiles" Condition="WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallServicesTask" After="InstallFinalize" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<!-- TODO: Use to activate embedded MSIX -->
|
||||
<!--<Custom Action="UninstallEmbeddedMSIXTask" After="InstallFinalize">
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj",
|
||||
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI\\Microsoft.CmdPal.UI.csproj",
|
||||
"src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Core.Common.UnitTests\\Microsoft.CmdPal.Core.Common.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Apps.UnitTests\\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
|
||||
|
||||
@@ -9,4 +9,18 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal file
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Properties {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.Common.Properties.Resources", typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This is an error report generated by Windows Command Palette.
|
||||
///If you are seeing this, it means something went a little sideways in the app.
|
||||
///You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
///
|
||||
///(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.).
|
||||
/// </summary>
|
||||
internal static string ErrorReport_Global_Preamble {
|
||||
get {
|
||||
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ErrorReport_Global_Preamble" xml:space="preserve">
|
||||
<value>This is an error report generated by Windows Command Palette.
|
||||
If you are seeing this, it means something went a little sideways in the app.
|
||||
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
|
||||
(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.)</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
|
||||
public sealed class ErrorReportBuilder : IErrorReportBuilder
|
||||
{
|
||||
private readonly ErrorReportSanitizer _sanitizer = new();
|
||||
|
||||
private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble;
|
||||
|
||||
public string BuildReport(Exception exception, string context, bool redactPii = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(exception);
|
||||
|
||||
var exceptionMessage = CoalesceExceptionMessage(exception);
|
||||
var sanitizedMessage = redactPii ? _sanitizer.Sanitize(exceptionMessage) : exceptionMessage;
|
||||
var sanitizedFormattedException = redactPii ? _sanitizer.Sanitize(exception.ToString()) : exception.ToString();
|
||||
|
||||
// Note:
|
||||
// - do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
// - keep timestamp format should be consistent with the log (makes it easier to search)
|
||||
var technicalContent =
|
||||
$"""
|
||||
============================================================
|
||||
Summary:
|
||||
Message: {sanitizedMessage}
|
||||
Type: {exception.GetType().FullName}
|
||||
Source: {exception.Source ?? "N/A"}
|
||||
Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fffffff}
|
||||
HRESULT: 0x{exception.HResult:X8} ({exception.HResult})
|
||||
Context: {context ?? "N/A"}
|
||||
|
||||
Application:
|
||||
App version: {GetAppVersionSafe()}
|
||||
Is elevated: {GetElevationStatus()}
|
||||
|
||||
Environment:
|
||||
OS version: {RuntimeInformation.OSDescription}
|
||||
OS architecture: {RuntimeInformation.OSArchitecture}
|
||||
Runtime identifier: {RuntimeInformation.RuntimeIdentifier}
|
||||
Framework: {RuntimeInformation.FrameworkDescription}
|
||||
Process architecture: {RuntimeInformation.ProcessArchitecture}
|
||||
Culture: {CultureInfo.CurrentCulture.Name}
|
||||
UI culture: {CultureInfo.CurrentUICulture.Name}
|
||||
|
||||
Stack Trace:
|
||||
{exception.StackTrace}
|
||||
|
||||
------------------ Full Exception Details ------------------
|
||||
{sanitizedFormattedException}
|
||||
|
||||
============================================================
|
||||
""";
|
||||
|
||||
return $"""
|
||||
{Preamble}
|
||||
{technicalContent}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string GetElevationStatus()
|
||||
{
|
||||
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
try
|
||||
{
|
||||
var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
|
||||
return isElevated ? "yes" : "no";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Failed to determine elevation status";
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAppVersionSafe()
|
||||
{
|
||||
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
|
||||
try
|
||||
{
|
||||
var version = Package.Current.Id.Version;
|
||||
return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Failed to retrieve app version";
|
||||
}
|
||||
}
|
||||
|
||||
private static string CoalesceExceptionMessage(Exception exception)
|
||||
{
|
||||
// let's try to get a message from the exception or inferred it from the HRESULT
|
||||
// to show at least something
|
||||
var message = exception.Message;
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
var temp = Marshal.GetExceptionForHR(exception.HResult)?.Message;
|
||||
if (!string.IsNullOrWhiteSpace(temp))
|
||||
{
|
||||
message = temp + $" (inferred from HRESULT 0x{exception.HResult:X8})";
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
message = "No message available";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a contract for creating human-readable error reports from exceptions,
|
||||
/// suitable for logs, telemetry, or user-facing diagnostics.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations should ensure reports are consistent and optionally redact
|
||||
/// personally identifiable or sensitive information when requested.
|
||||
/// </remarks>
|
||||
public interface IErrorReportBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a formatted error report for the specified <paramref name="exception"/> and <paramref name="context"/>.
|
||||
/// </summary>
|
||||
/// <param name="exception">The exception that triggered the error report.</param>
|
||||
/// <param name="context">
|
||||
/// A short, human-readable description of where or what was being executed when the error occurred
|
||||
/// (e.g., the operation name, component, or scenario).
|
||||
/// </param>
|
||||
/// <param name="redactPii">
|
||||
/// When true, attempts to remove or obfuscate personally identifiable or sensitive information
|
||||
/// (such as file paths, emails, machine/usernames, tokens). Defaults to true.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// A formatted string containing the error report, suitable for logging or telemetry submission.
|
||||
/// </returns>
|
||||
string BuildReport(Exception exception, string context, bool redactPii = true);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a service that sanitizes text by applying a set of configurable, regex-based rules.
|
||||
/// Typical use cases include masking secrets, removing PII, or normalizing logs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// - Rules are applied in their registered order; rule ordering may affect the final output.
|
||||
/// - Each rule should have a unique <c>description</c> that acts as its identifier.
|
||||
/// </remarks>
|
||||
/// <seealso cref="SanitizationRule"/>
|
||||
public interface ITextSanitizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Sanitizes the specified input by applying all registered rules in order.
|
||||
/// </summary>
|
||||
/// <param name="input">The input text to sanitize. Implementations should handle <see langword="null"/> safely.</param>
|
||||
/// <returns>The sanitized text after all rules are applied.</returns>
|
||||
string Sanitize(string? input);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a sanitization rule using a .NET regular expression pattern and a replacement string.
|
||||
/// </summary>
|
||||
/// <param name="pattern">A .NET regular expression pattern used to match text to sanitize.</param>
|
||||
/// <param name="replacement">
|
||||
/// The replacement text used by <c>Regex.Replace</c>. Supports standard regex replacement tokens,
|
||||
/// including numbered groups (<c>$1</c>) and named groups (<c>${name}</c>).
|
||||
/// </param>
|
||||
/// <param name="description">
|
||||
/// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Implementations typically validate <paramref name="pattern"/> is a valid regex and may reject duplicate <paramref name="description"/> values.
|
||||
/// </remarks>
|
||||
void AddRule(string pattern, string replacement, string description = "");
|
||||
|
||||
/// <summary>
|
||||
/// Removes a previously added rule identified by its <paramref name="description"/>.
|
||||
/// </summary>
|
||||
/// <param name="description">The unique description of the rule to remove.</param>
|
||||
void RemoveRule(string description);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a read-only snapshot of the currently registered sanitization rules in application order.
|
||||
/// </summary>
|
||||
/// <returns>A read-only list of <see cref="SanitizationRule"/> items.</returns>
|
||||
IReadOnlyList<SanitizationRule> GetRules();
|
||||
|
||||
/// <summary>
|
||||
/// Tests a single rule, identified by <paramref name="ruleDescription"/>, against the provided <paramref name="input"/>,
|
||||
/// without applying other rules.
|
||||
/// </summary>
|
||||
/// <param name="input">The input text to test.</param>
|
||||
/// <param name="ruleDescription">The description (identifier) of the rule to test.</param>
|
||||
/// <returns>The result of applying only the specified rule to the input.</returns>
|
||||
string TestRule(string input, string ruleDescription);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
public readonly record struct SanitizationRule
|
||||
{
|
||||
public SanitizationRule(Regex regex, string replacement, string description = "")
|
||||
{
|
||||
Regex = regex;
|
||||
Replacement = replacement;
|
||||
Evaluator = null;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public SanitizationRule(Regex regex, MatchEvaluator evaluator, string description = "")
|
||||
{
|
||||
Regex = regex;
|
||||
Evaluator = evaluator;
|
||||
Replacement = null;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public Regex Regex { get; }
|
||||
|
||||
public string? Replacement { get; }
|
||||
|
||||
public MatchEvaluator? Evaluator { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public override string ToString() => $"{Description}: {Regex} -> {Replacement ?? "<evaluator>"}";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class ConnectionStringRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
[GeneratedRegex(@"(Server|Data Source|Initial Catalog|Database|User ID|Username|Password|Pwd|Uid)\s*=\s*(?:""[^""]*""|'[^']*'|[^;,\s]+)",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex ConnectionParamRx();
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(ConnectionParamRx(), "$1=[REDACTED]", "Connection string parameters");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class EnvironmentPropertiesRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
List<SanitizationRule> rules = [];
|
||||
|
||||
var machine = Environment.MachineName;
|
||||
if (!string.IsNullOrWhiteSpace(machine))
|
||||
{
|
||||
var rx = new Regex(@"\b" + Regex.Escape(machine) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
rules.Add(new(rx, "[MACHINE_NAME_REDACTED]", "Machine name"));
|
||||
}
|
||||
|
||||
var domain = Environment.UserDomainName;
|
||||
if (!string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
var rx = new Regex(@"\b" + Regex.Escape(domain) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
rules.Add(new(rx, "[USER_DOMAIN_NAME_REDACTED]", "User domain name"));
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
/// <summary>
|
||||
/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer.
|
||||
/// </summary>
|
||||
public sealed class ErrorReportSanitizer
|
||||
{
|
||||
private readonly TextSanitizer _sanitizer = new(BuildProviders(), onGuardrailTriggered: OnGuardrailTriggered);
|
||||
|
||||
private static void OnGuardrailTriggered(GuardrailEventArgs eventArgs)
|
||||
{
|
||||
var msg = $"Sanitization guardrail triggered for rule '{eventArgs.RuleDescription}': original length={eventArgs.OriginalLength}, result length={eventArgs.ResultLength}, ratio={eventArgs.Ratio:F2}, threshold={eventArgs.Threshold:F2}";
|
||||
CoreLogger.LogDebug(msg);
|
||||
}
|
||||
|
||||
private static IEnumerable<ISanitizationRuleProvider> BuildProviders()
|
||||
{
|
||||
// Order matters
|
||||
return
|
||||
[
|
||||
new PiiRuleProvider(),
|
||||
new UrlRuleProvider(),
|
||||
new NetworkRuleProvider(),
|
||||
new TokenRuleProvider(),
|
||||
new ConnectionStringRuleProvider(),
|
||||
new SecretKeyValueRulesProvider(),
|
||||
new EnvironmentPropertiesRuleProvider(),
|
||||
new FilenameMaskRuleProvider(),
|
||||
new ProfilePathAndUsernameRuleProvider()
|
||||
];
|
||||
}
|
||||
|
||||
public string Sanitize(string? input) => _sanitizer.Sanitize(input);
|
||||
|
||||
public string SanitizeException(Exception? exception)
|
||||
{
|
||||
if (exception is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var fullMessage = GetFullExceptionMessage(exception);
|
||||
return Sanitize(fullMessage);
|
||||
}
|
||||
|
||||
private static string GetFullExceptionMessage(Exception exception)
|
||||
{
|
||||
List<string> messages = [];
|
||||
var current = exception;
|
||||
var depth = 0;
|
||||
|
||||
// Prevent infinite loops on pathological InnerException graphs
|
||||
while (current is not null && depth < 10)
|
||||
{
|
||||
messages.Add($"{current.GetType().Name}: {current.Message}");
|
||||
|
||||
if (!string.IsNullOrEmpty(current.StackTrace))
|
||||
{
|
||||
messages.Add($"Stack Trace: {current.StackTrace}");
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, messages);
|
||||
}
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
=> _sanitizer.AddRule(pattern, replacement, description);
|
||||
|
||||
public void RemoveRule(string description)
|
||||
=> _sanitizer.RemoveRule(description);
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _sanitizer.GetRules();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
=> _sanitizer.TestRule(input, ruleDescription);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
private static readonly FrozenSet<string> CommonFileStemExclusions = new[]
|
||||
{
|
||||
"settings",
|
||||
"config",
|
||||
"configuration",
|
||||
"appsettings",
|
||||
"options",
|
||||
"prefs",
|
||||
"preferences",
|
||||
"squirrel",
|
||||
"app",
|
||||
"system",
|
||||
"env",
|
||||
"environment",
|
||||
"manifest",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
const string pattern = """
|
||||
(?<full>
|
||||
(?: [A-Za-z]: )? (?: [\\/][^\\/:*?""<>|\s]+ )+ # drive-rooted or UNC-like
|
||||
| [^\\/:*?""<>|\s]+ (?: [\\/][^\\/:*?""<>|\s]+ )+ # relative with at least one sep
|
||||
)
|
||||
""";
|
||||
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs));
|
||||
yield return new SanitizationRule(rx, MatchEvaluator, "Mask filename in any path");
|
||||
yield break;
|
||||
|
||||
static string MatchEvaluator(Match m)
|
||||
{
|
||||
var full = m.Groups["full"].Value;
|
||||
|
||||
var lastSep = Math.Max(full.LastIndexOf('\\'), full.LastIndexOf('/'));
|
||||
if (lastSep < 0 || lastSep == full.Length - 1)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
var dir = full[..(lastSep + 1)];
|
||||
var file = full[(lastSep + 1)..];
|
||||
|
||||
var dot = file.LastIndexOf('.');
|
||||
var looksLikeFile = (dot > 0 && dot < file.Length - 1) || (file.StartsWith('.') && file.Length > 1);
|
||||
|
||||
if (!looksLikeFile)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
string stem, ext;
|
||||
if (dot > 0 && dot < file.Length - 1)
|
||||
{
|
||||
stem = file[..dot];
|
||||
ext = file[dot..];
|
||||
}
|
||||
else
|
||||
{
|
||||
stem = file;
|
||||
ext = string.Empty;
|
||||
}
|
||||
|
||||
if (!ShouldMaskFileName(stem))
|
||||
{
|
||||
return dir + file;
|
||||
}
|
||||
|
||||
var masked = MaskStem(stem) + ext;
|
||||
return dir + masked;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeStem(string stem)
|
||||
{
|
||||
return stem.Replace("-", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(".", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool ShouldMaskFileName(string stem)
|
||||
{
|
||||
return !CommonFileStemExclusions.Contains(NormalizeStem(stem));
|
||||
}
|
||||
|
||||
private static string MaskStem(string stem)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stem))
|
||||
{
|
||||
return stem;
|
||||
}
|
||||
|
||||
var keep = Math.Min(2, stem.Length);
|
||||
var maskedCount = Math.Max(1, stem.Length - keep);
|
||||
return stem[..keep] + new string('*', maskedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
public record GuardrailEventArgs(
|
||||
string RuleDescription,
|
||||
int OriginalLength,
|
||||
int ResultLength,
|
||||
double Threshold)
|
||||
{
|
||||
public double Ratio => OriginalLength > 0 ? (double)ResultLength / OriginalLength : 1.0;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal interface ISanitizationRuleProvider
|
||||
{
|
||||
IEnumerable<SanitizationRule> GetRules();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses");
|
||||
yield return new(Ipv6BracketedRx(), "[IP6_REDACTED]", "IPv6 addresses (bracketed/with port)");
|
||||
yield return new(Ipv6Rx(), "[IP6_REDACTED]", "IPv6 addresses");
|
||||
yield return new(MacAddressRx(), "[MAC_ADDRESS_REDACTED]", "MAC addresses");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv4Rx();
|
||||
|
||||
[GeneratedRegex(
|
||||
"""
|
||||
(?ix) # ignore case/whitespace
|
||||
(?<![A-F0-9:]) # left edge
|
||||
(
|
||||
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} | # 1:2:3:4:5:6:7:8
|
||||
(?:[A-F0-9]{1,4}:){1,7}: | # 1:: 1:2:...:7::
|
||||
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
|
||||
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
|
||||
:(?::[A-F0-9]{1,4}){1,7} | # ::, ::1, etc.
|
||||
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} | # IPv4 tail
|
||||
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
:(?:\d{1,3}\.){3}\d{1,3}
|
||||
)
|
||||
(?:%\w+)? # optional zone id
|
||||
(?![A-F0-9:]) # right edge
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv6Rx();
|
||||
|
||||
[GeneratedRegex(
|
||||
"""
|
||||
(?ix)
|
||||
\[
|
||||
(
|
||||
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,7}: |
|
||||
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
|
||||
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
|
||||
:(?::[A-F0-9]{1,4}){1,7} |
|
||||
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} |
|
||||
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
|
||||
:(?:\d{1,3}\.){3}\d{1,3}
|
||||
)
|
||||
(?:%\w+)? # optional zone id
|
||||
\]
|
||||
(?: : (?<port>\d{1,5}) )? # optional port
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex Ipv6BracketedRx();
|
||||
|
||||
[GeneratedRegex(@"\b(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}|[0-9A-Fa-f]{1,2})\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex MacAddressRx();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(EmailRx(), "[EMAIL_REDACTED]", "Email addresses");
|
||||
yield return new(SsnRx(), "[SSN_REDACTED]", "Social Security Numbers");
|
||||
yield return new(CreditCardRx(), "[CARD_REDACTED]", "Credit card numbers");
|
||||
|
||||
// phone number regex is the most generic, so it goes last
|
||||
// we can't make this too generic; otherwise we over-redact error codes, dates, etc.
|
||||
yield return new(PhoneRx(), "[PHONE_REDACTED]", "Phone numbers");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b[a-zA-Z0-9]([a-zA-Z0-9._%-]*[a-zA-Z0-9])?@[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex EmailRx();
|
||||
|
||||
[GeneratedRegex("""
|
||||
(?xi)
|
||||
# ---------- boundaries ----------
|
||||
(?<!\w) # not after a letter/digit/underscore
|
||||
(?<![A-Za-z0-9]-) # avoid starting inside hyphenated tokens (GUID middles, etc.)
|
||||
|
||||
# ---------- global do-not-match guards ----------
|
||||
(?! # ISO date (yyyy-mm-dd / yyyy.mm.dd / yyyy/mm/dd)
|
||||
(?:19|20)\d{2}[-./](?:0[1-9]|1[0-2])[-./](?:0[1-9]|[12]\d|3[01])\b
|
||||
)
|
||||
(?! # EU date (dd-mm-yyyy / dd.mm.yyyy / dd/mm/yyyy)
|
||||
(?:0[1-9]|[12]\d|3[01])[-./](?:0[1-9]|1[0-2])[-./](?:19|20)\d{2}\b
|
||||
)
|
||||
(?! # ISO datetime like 2025-08-24T14:32[:ss][Z|±hh:mm]
|
||||
(?:19|20)\d{2}-\d{2}-\d{2}[T\s]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\b
|
||||
)
|
||||
(?!\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b) # IPv4 with optional :port
|
||||
(?!\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b) # GUID, lowercase
|
||||
(?!\b[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}\b) # GUID, uppercase
|
||||
(?!\bv?\d+(?:\.\d+){2,}\b) # semantic/file versions like 1.2.3 or 10.0.22631.3448
|
||||
(?!\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b) # MAC address
|
||||
|
||||
# ---------- digit budget ----------
|
||||
(?=(?:\D*\d){7,15}) # 7–15 digits in total
|
||||
|
||||
# ---------- number body ----------
|
||||
(?:
|
||||
# A with explicit country code, allow compact digits (E.164-ish) or grouped
|
||||
(?:\+|00)[1-9]\d{0,2}
|
||||
(?:
|
||||
[\p{Zs}.\-\/]*\d{6,14}
|
||||
|
|
||||
[\p{Zs}.\-\/]* (?:\(\d{1,4}\)|\d{1,4})
|
||||
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
|
||||
)
|
||||
|
|
||||
# B no country code => require separators between blocks (avoid plain big ints)
|
||||
(?:\(\d{1,4}\)|\d{1,4})
|
||||
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
|
||||
)
|
||||
|
||||
# ---------- optional extension ----------
|
||||
(?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?<ext>\d{1,6}))?
|
||||
|
||||
(?!-\w) # don't end just before '-letter'/'-digit'
|
||||
""",
|
||||
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex PhoneRx();
|
||||
|
||||
[GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex SsnRx();
|
||||
|
||||
[GeneratedRegex(@"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex CreditCardRx();
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class ProfilePathAndUsernameRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
|
||||
|
||||
private readonly Dictionary<string, string> _profilePaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _usernames = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly FrozenSet<string> CommonPathParts = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Users", "home", "Documents", "Desktop", "AppData", "Local", "Roaming",
|
||||
"Pictures", "Videos", "Music", "Downloads", "Program Files", "Windows",
|
||||
"System32", "bin", "usr", "var", "etc", "opt", "tmp",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly FrozenSet<string> CommonWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"admin", "user", "test", "guest", "public", "system", "service",
|
||||
"default", "temp", "local", "shared", "common", "data", "config",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ProfilePathAndUsernameRuleProvider()
|
||||
{
|
||||
DetectSystemPaths();
|
||||
}
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
List<SanitizationRule> rules = [];
|
||||
|
||||
// Profile path rules (ordered longest-first)
|
||||
var orderedRules = _profilePaths
|
||||
.Where(p => !string.IsNullOrEmpty(p.Key))
|
||||
.OrderByDescending(p => p.Key.Length);
|
||||
|
||||
foreach (var profilePath in orderedRules)
|
||||
{
|
||||
try
|
||||
{
|
||||
var normalizedPath = profilePath.Key
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
var escapedPath = Regex.Escape(normalizedPath);
|
||||
|
||||
var pattern = escapedPath + @"(?:[/\\]*)";
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
|
||||
rules.Add(new(rx, profilePath.Value, $"Profile path: {profilePath}"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic paths
|
||||
}
|
||||
}
|
||||
|
||||
// Username rules
|
||||
foreach (var username in _usernames.Where(u => !string.IsNullOrEmpty(u) && u.Length > 2))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!IsLikelyUsername(username))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rx = new Regex(@"\b" + Regex.Escape(username) + @"\b", SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
rules.Add(new(rx, "[USERNAME_REDACTED]", $"Username: {username}"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip problematic usernames
|
||||
}
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> GetDetectedProfilePaths() => _profilePaths;
|
||||
|
||||
public IReadOnlyCollection<string> GetDetectedUsernames() => _usernames;
|
||||
|
||||
private void DetectSystemPaths()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(userProfile) && Directory.Exists(userProfile))
|
||||
{
|
||||
_profilePaths.Add(userProfile, "[USER_PROFILE_DIR]");
|
||||
var username = Path.GetFileName(userProfile);
|
||||
if (!string.IsNullOrEmpty(username) && username.Length > 2)
|
||||
{
|
||||
_usernames.Add(username);
|
||||
}
|
||||
}
|
||||
|
||||
Environment.SpecialFolder[] profileFolders =
|
||||
[
|
||||
Environment.SpecialFolder.ApplicationData,
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolder.Desktop,
|
||||
Environment.SpecialFolder.MyDocuments,
|
||||
Environment.SpecialFolder.MyPictures,
|
||||
Environment.SpecialFolder.MyVideos,
|
||||
Environment.SpecialFolder.MyMusic,
|
||||
Environment.SpecialFolder.StartMenu,
|
||||
Environment.SpecialFolder.Startup,
|
||||
Environment.SpecialFolder.DesktopDirectory
|
||||
];
|
||||
|
||||
foreach (var folder in profileFolders)
|
||||
{
|
||||
var dir = Environment.GetFolderPath(folder);
|
||||
if (string.IsNullOrEmpty(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var added = _profilePaths.TryAdd(dir, $"[{folder.ToString().ToUpperInvariant()}_DIR]");
|
||||
if (!added)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
string[] envVars = ["USERPROFILE", "HOME", "OneDrive", "OneDriveCommercial"];
|
||||
foreach (var envVar in envVars)
|
||||
{
|
||||
var envPath = Environment.GetEnvironmentVariable(envVar);
|
||||
if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath))
|
||||
{
|
||||
_profilePaths.TryAdd(envPath, $"[{envVar.ToUpperInvariant()}_DIR]");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Error detecting system profile paths and usernames", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLikelyUsername(string username) =>
|
||||
!CommonWords.Contains(username) &&
|
||||
username.Length is >= 3 and <= 50 &&
|
||||
!username.All(char.IsDigit);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal static class SanitizerDefaults
|
||||
{
|
||||
public const RegexOptions DefaultOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled;
|
||||
public const int DefaultMatchTimeoutMs = 100;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed class SecretKeyValueRulesProvider : ISanitizationRuleProvider
|
||||
{
|
||||
// Central list of common secret keys/phrases to redact when found in key=value pairs.
|
||||
private static readonly FrozenSet<string> SecretKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Core passwords/secrets
|
||||
"password",
|
||||
"passphrase",
|
||||
"passwd",
|
||||
"pwd",
|
||||
|
||||
// Tokens
|
||||
"token",
|
||||
"access token",
|
||||
"refresh token",
|
||||
"id token",
|
||||
"auth token",
|
||||
"session token",
|
||||
"bearer token",
|
||||
"personal access token",
|
||||
"pat",
|
||||
|
||||
// API / client credentials
|
||||
"api key",
|
||||
"api secret",
|
||||
"x api key",
|
||||
"client id",
|
||||
"client secret",
|
||||
"x client id",
|
||||
"x client secret",
|
||||
"consumer secret",
|
||||
"service principal secret",
|
||||
|
||||
// Cloud & platform (Azure/AppInsights/etc.)
|
||||
"subscription key",
|
||||
"instrumentation key",
|
||||
"account key",
|
||||
"storage account key",
|
||||
"shared access key",
|
||||
"shared access signature",
|
||||
"SAS token",
|
||||
|
||||
// Connection strings (often surfaced in exception messages)
|
||||
"connection string",
|
||||
"conn string",
|
||||
"storage connection string",
|
||||
|
||||
// Certificates & crypto
|
||||
"private key",
|
||||
"certificate password",
|
||||
"client certificate password",
|
||||
"pfx password",
|
||||
|
||||
// AWS common keys
|
||||
"aws access key id",
|
||||
"aws secret access key",
|
||||
"aws session token",
|
||||
|
||||
// Optional service aliases
|
||||
"cosmos db key",
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return BuildSecretKeyValueRule(
|
||||
SecretKeys,
|
||||
timeout: TimeSpan.FromSeconds(5),
|
||||
starEverything: true);
|
||||
}
|
||||
|
||||
private static SanitizationRule BuildSecretKeyValueRule(
|
||||
IEnumerable<string> keys,
|
||||
RegexOptions? options = null,
|
||||
TimeSpan? timeout = null,
|
||||
string label = "[REDACTED]",
|
||||
bool treatDashUnderscoreAsSpace = true,
|
||||
string separatorsClass = "[:=]", // char class for separators
|
||||
string unquotedStopClass = "\\s",
|
||||
bool starEverything = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keys);
|
||||
|
||||
// Between-word matcher for keys: "api key" -> "api\s*key" (optionally treating _/- as "space")
|
||||
var between = treatDashUnderscoreAsSpace ? @"(?:\s|[_-])*" : @"\s*";
|
||||
|
||||
var patterns = new List<string>();
|
||||
|
||||
foreach (var raw in keys)
|
||||
{
|
||||
var key = raw?.Trim();
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (starEverything && key is not ['*', ..])
|
||||
{
|
||||
key = "*" + key;
|
||||
}
|
||||
|
||||
if (key is ['*', .. var tail])
|
||||
{
|
||||
// Wildcard prefix: allow one non-space token + optional "-" or "_" before the remainder.
|
||||
// Matches: "api key", "api-key", "azure-api-key", "user_api_key"
|
||||
var remainder = tail.Trim();
|
||||
if (remainder.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rem = Normalize(remainder, between);
|
||||
patterns.Add($@"(?:(?>[A-Za-z0-9_]{{1,128}}[_-]))?{rem}");
|
||||
}
|
||||
else
|
||||
{
|
||||
patterns.Add(Normalize(key, between));
|
||||
}
|
||||
}
|
||||
|
||||
if (patterns.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("No non-empty keys provided.", nameof(keys));
|
||||
}
|
||||
|
||||
var keysAlt = string.Join("|", patterns);
|
||||
|
||||
var pattern =
|
||||
$"""
|
||||
# Negative lookbehind to ensure the key is not part of a larger word
|
||||
(?<![A-Za-z0-9])
|
||||
# Match and capture the key (from the provided list)
|
||||
(?<key>(?:{keysAlt}))
|
||||
# Negative lookahead to ensure the key is not part of a larger word
|
||||
(?![A-Za-z0-9])
|
||||
# Optional whitespace between key and separator
|
||||
\s*
|
||||
# Separator (e.g., ':' or '=')
|
||||
(?<sep>{separatorsClass})
|
||||
# Optional whitespace after separator
|
||||
\s*
|
||||
# Match and capture the value, supporting quoted or unquoted values
|
||||
(?:
|
||||
# Quoted value: match opening quote, value, and closing quote
|
||||
(?<q>["'])(?<val>[^"']+)\k<q>
|
||||
|
|
||||
# Unquoted value: match up to the next whitespace
|
||||
(?<val>[^{unquotedStopClass}]+)
|
||||
)
|
||||
""";
|
||||
|
||||
var rx = new Regex(
|
||||
pattern,
|
||||
(options ?? (RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) | RegexOptions.IgnorePatternWhitespace,
|
||||
timeout ?? TimeSpan.FromMilliseconds(1000));
|
||||
|
||||
var replacement = @"${key}${sep} ${q}" + label + @"${q}";
|
||||
return new SanitizationRule(rx, replacement, "Sensitive key/value pairs");
|
||||
|
||||
static string Normalize(string s, string betweenSep)
|
||||
=> Regex.Escape(s).Replace("\\ ", betweenSep);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
/// <summary>
|
||||
/// Generic text sanitizer that applies a sequence of regex-based rules over input text.
|
||||
/// </summary>
|
||||
internal sealed class TextSanitizer : ITextSanitizer
|
||||
{
|
||||
// Default guardrail: sanitized text must retain at least 30% of the original length
|
||||
private const double DefaultGuardrailThreshold = 0.3;
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs);
|
||||
|
||||
private readonly List<SanitizationRule> _rules = [];
|
||||
private readonly double _guardrailThreshold;
|
||||
private readonly Action<GuardrailEventArgs>? _onGuardrailTriggered;
|
||||
|
||||
public TextSanitizer(
|
||||
double guardrailThreshold = DefaultGuardrailThreshold,
|
||||
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
|
||||
{
|
||||
_guardrailThreshold = guardrailThreshold;
|
||||
_onGuardrailTriggered = onGuardrailTriggered;
|
||||
}
|
||||
|
||||
public TextSanitizer(
|
||||
IEnumerable<ISanitizationRuleProvider> providers,
|
||||
double guardrailThreshold = DefaultGuardrailThreshold,
|
||||
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providers);
|
||||
_guardrailThreshold = guardrailThreshold;
|
||||
_onGuardrailTriggered = onGuardrailTriggered;
|
||||
|
||||
foreach (var p in providers)
|
||||
{
|
||||
try
|
||||
{
|
||||
_rules.AddRange(p.GetRules());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort; ignore provider errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Sanitize(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = input;
|
||||
|
||||
foreach (var rule in _rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
var previous = result;
|
||||
|
||||
result = rule.Evaluator is null
|
||||
? rule.Regex.Replace(previous, rule.Replacement!)
|
||||
: rule.Regex.Replace(previous, rule.Evaluator);
|
||||
|
||||
if (result.Length < previous.Length * _guardrailThreshold)
|
||||
{
|
||||
_onGuardrailTriggered?.Invoke(new GuardrailEventArgs(
|
||||
rule.Description,
|
||||
previous.Length,
|
||||
result.Length,
|
||||
_guardrailThreshold));
|
||||
result = previous; // Guardrail
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// Ignore timeouts; keep the original input
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore other exceptions; keep the original input
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
{
|
||||
var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout);
|
||||
_rules.Add(new SanitizationRule(rx, replacement, description));
|
||||
}
|
||||
|
||||
public void RemoveRule(string description)
|
||||
{
|
||||
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _rules.AsReadOnly();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
{
|
||||
var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase));
|
||||
if (rule.Regex is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (rule.Evaluator is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Evaluator);
|
||||
}
|
||||
|
||||
if (rule.Replacement is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Replacement);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions; return original input
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class TokenRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(JwtRx(), "[JWT_REDACTED]", "JSON Web Tokens (JWT)");
|
||||
yield return new(TokenRx(), "[TOKEN_REDACTED]", "Potential API keys/tokens");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex JwtRx();
|
||||
|
||||
[GeneratedRegex(@"\b[A-Za-z0-9]{32,128}\b",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex TokenRx();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
internal sealed partial class UrlRuleProvider : ISanitizationRuleProvider
|
||||
{
|
||||
public IEnumerable<SanitizationRule> GetRules()
|
||||
{
|
||||
yield return new(UrlRx(), "[URL_REDACTED]", "URLs");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\b(?:https?|ftp|ftps|file|jdbc|ldap|mailto)://[^\s<>""'{}\[\]\\^`|]+",
|
||||
SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)]
|
||||
private static partial Regex UrlRx();
|
||||
}
|
||||
@@ -43,4 +43,10 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>$(AssemblyName).UnitTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,7 @@ using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.Core.ViewModels.Models;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -23,6 +24,8 @@ public sealed class CommandProviderWrapper
|
||||
|
||||
private readonly TaskScheduler _taskScheduler;
|
||||
|
||||
private readonly ICommandProviderCache? _commandProviderCache;
|
||||
|
||||
public TopLevelViewModel[] TopLevelItems { get; private set; } = [];
|
||||
|
||||
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
|
||||
@@ -43,13 +46,7 @@ public sealed class CommandProviderWrapper
|
||||
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public string ProviderId
|
||||
{
|
||||
get
|
||||
{
|
||||
return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
|
||||
}
|
||||
}
|
||||
public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
|
||||
|
||||
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
|
||||
{
|
||||
@@ -77,9 +74,11 @@ public sealed class CommandProviderWrapper
|
||||
Logger.LogDebug($"Initialized command provider {ProviderId}");
|
||||
}
|
||||
|
||||
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread)
|
||||
public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread, ICommandProviderCache commandProviderCache)
|
||||
{
|
||||
_taskScheduler = mainThread;
|
||||
_commandProviderCache = commandProviderCache;
|
||||
|
||||
Extension = extension;
|
||||
ExtensionHost = new CommandPaletteHost(extension);
|
||||
if (!Extension.IsRunning())
|
||||
@@ -128,30 +127,31 @@ public sealed class CommandProviderWrapper
|
||||
if (!isValid)
|
||||
{
|
||||
IsActive = false;
|
||||
RecallFromCache();
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = serviceProvider.GetService<SettingsModel>()!;
|
||||
|
||||
IsActive = GetProviderSettings(settings).IsEnabled;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
IsActive = providerSettings.IsEnabled;
|
||||
if (!IsActive)
|
||||
{
|
||||
RecallFromCache();
|
||||
return;
|
||||
}
|
||||
|
||||
ICommandItem[]? commands = null;
|
||||
IFallbackCommandItem[]? fallbacks = null;
|
||||
|
||||
var displayInfoInitialized = false;
|
||||
try
|
||||
{
|
||||
var model = _commandProvider.Unsafe!;
|
||||
|
||||
Task<ICommandItem[]> t = new(model.TopLevelCommands);
|
||||
t.Start();
|
||||
commands = await t.ConfigureAwait(false);
|
||||
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
|
||||
loadTopLevelCommandsTask.Start();
|
||||
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
|
||||
|
||||
// On a BG thread here
|
||||
fallbacks = model.FallbackCommands();
|
||||
var fallbacks = model.FallbackCommands();
|
||||
|
||||
if (model is ICommandProvider2 two)
|
||||
{
|
||||
@@ -162,6 +162,13 @@ public sealed class CommandProviderWrapper
|
||||
DisplayName = model.DisplayName;
|
||||
Icon = new(model.Icon);
|
||||
Icon.InitializeProperties();
|
||||
displayInfoInitialized = true;
|
||||
|
||||
// Update cached display name
|
||||
if (_commandProviderCache is not null && Extension?.ExtensionUniqueId is not null)
|
||||
{
|
||||
_commandProviderCache.Memorize(Extension.ExtensionUniqueId, new CommandProviderCacheItem(model.DisplayName));
|
||||
}
|
||||
|
||||
// Note: explicitly not InitializeProperties()ing the settings here. If
|
||||
// we do that, then we'd regress GH #38321
|
||||
@@ -177,6 +184,25 @@ public sealed class CommandProviderWrapper
|
||||
Logger.LogError("Failed to load commands from extension");
|
||||
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
|
||||
Logger.LogError(e.ToString());
|
||||
|
||||
if (!displayInfoInitialized)
|
||||
{
|
||||
RecallFromCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RecallFromCache()
|
||||
{
|
||||
var cached = _commandProviderCache?.Recall(ProviderId);
|
||||
if (cached is not null)
|
||||
{
|
||||
DisplayName = cached.DisplayName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DisplayName))
|
||||
{
|
||||
DisplayName = Extension?.PackageDisplayName ?? Extension?.PackageFamilyName ?? ProviderId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +211,7 @@ public sealed class CommandProviderWrapper
|
||||
var settings = serviceProvider.GetService<SettingsModel>()!;
|
||||
var providerSettings = GetProviderSettings(settings);
|
||||
|
||||
Func<ICommandItem?, bool, TopLevelViewModel> makeAndAdd = (ICommandItem? i, bool fallback) =>
|
||||
var makeAndAdd = (ICommandItem? i, bool fallback) =>
|
||||
{
|
||||
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
|
||||
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i);
|
||||
|
||||
@@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,4 @@
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Messages;
|
||||
|
||||
public record OpenSettingsMessage()
|
||||
{
|
||||
}
|
||||
public record OpenSettingsMessage(string SettingsPageTag = "");
|
||||
|
||||
@@ -14,11 +14,13 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ProviderSettingsViewModel : ObservableObject
|
||||
{
|
||||
private static readonly IconInfoViewModel EmptyIcon = new(null);
|
||||
|
||||
private readonly CommandProviderWrapper _provider;
|
||||
private readonly ProviderSettings _providerSettings;
|
||||
private readonly SettingsModel _settings;
|
||||
|
||||
private readonly Lock _initializeSettingsLock = new();
|
||||
|
||||
private Task? _initializeSettingsTask;
|
||||
|
||||
public ProviderSettingsViewModel(
|
||||
@@ -43,7 +45,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
HasFallbackCommands ?
|
||||
$"{ExtensionName}, {TopLevelCommands.Count} commands, {_provider.FallbackItems?.Length} fallback commands" :
|
||||
$"{ExtensionName}, {TopLevelCommands.Count} commands" :
|
||||
Resources.builtin_disabled_extension;
|
||||
$"{ExtensionName}, {Resources.builtin_disabled_extension}";
|
||||
|
||||
[MemberNotNullWhen(true, nameof(Extension))]
|
||||
public bool IsFromExtension => _provider.Extension is not null;
|
||||
@@ -52,7 +54,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
|
||||
public string ExtensionVersion => IsFromExtension ? $"{Extension.Version.Major}.{Extension.Version.Minor}.{Extension.Version.Build}.{Extension.Version.Revision}" : string.Empty;
|
||||
|
||||
public IconInfoViewModel Icon => _provider.Icon;
|
||||
public IconInfoViewModel Icon => IsEnabled ? _provider.Icon : EmptyIcon;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LoadingSettings { get; set; }
|
||||
@@ -69,6 +71,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
||||
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new());
|
||||
OnPropertyChanged(nameof(IsEnabled));
|
||||
OnPropertyChanged(nameof(ExtensionSubtext));
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
|
||||
if (value == true)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
internal sealed class CommandProviderCacheContainer
|
||||
{
|
||||
public Dictionary<string, CommandProviderCacheItem> Cache { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public record CommandProviderCacheItem(string DisplayName);
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
[JsonSerializable(typeof(CommandProviderCacheItem))]
|
||||
[JsonSerializable(typeof(Dictionary<string, CommandProviderCacheItem>))]
|
||||
[JsonSerializable(typeof(CommandProviderCacheContainer))]
|
||||
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = false)]
|
||||
internal sealed partial class CommandProviderCacheSerializationContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public sealed partial class DefaultCommandProviderCache : ICommandProviderCache, IDisposable
|
||||
{
|
||||
private const string CacheFileName = "commandProviderCache.json";
|
||||
|
||||
private readonly Dictionary<string, CommandProviderCacheItem> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly Lock _sync = new();
|
||||
|
||||
private readonly SupersedingAsyncGate _saveGate;
|
||||
|
||||
public DefaultCommandProviderCache()
|
||||
{
|
||||
_saveGate = new SupersedingAsyncGate(async _ => await TrySaveAsync().ConfigureAwait(false));
|
||||
TryLoad();
|
||||
}
|
||||
|
||||
public void Memorize(string providerId, CommandProviderCacheItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerId);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_cache[providerId] = item;
|
||||
}
|
||||
|
||||
_ = _saveGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
public CommandProviderCacheItem? Recall(string providerId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(providerId);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_cache.TryGetValue(providerId, out var item);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetCacheFilePath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, CacheFileName);
|
||||
}
|
||||
|
||||
private void TryLoad()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = GetCacheFilePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loaded = JsonSerializer.Deserialize(
|
||||
json,
|
||||
CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
|
||||
if (loaded?.Cache is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cache.Clear();
|
||||
foreach (var kvp in loaded.Cache)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(kvp.Key) && kvp.Value is not null)
|
||||
{
|
||||
_cache[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to load command provider cache: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TrySaveAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<string, CommandProviderCacheItem> snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
snapshot = new Dictionary<string, CommandProviderCacheItem>(_cache, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var container = new CommandProviderCacheContainer
|
||||
{
|
||||
Cache = snapshot,
|
||||
};
|
||||
|
||||
var path = GetCacheFilePath();
|
||||
var json = JsonSerializer.Serialize(container, CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!);
|
||||
await File.WriteAllTextAsync(path, json).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to save command provider cache: ", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_saveGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
|
||||
public interface ICommandProviderCache
|
||||
{
|
||||
void Memorize(string providerId, CommandProviderCacheItem item);
|
||||
|
||||
CommandProviderCacheItem? Recall(string providerId);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -25,6 +26,7 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
IDisposable
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ICommandProviderCache _commandProviderCache;
|
||||
private readonly TaskScheduler _taskScheduler;
|
||||
|
||||
private readonly List<CommandProviderWrapper> _builtInCommands = [];
|
||||
@@ -34,9 +36,10 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
|
||||
TaskScheduler IPageContext.Scheduler => _taskScheduler;
|
||||
|
||||
public TopLevelCommandManager(IServiceProvider serviceProvider)
|
||||
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_commandProviderCache = commandProviderCache;
|
||||
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
|
||||
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
|
||||
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
|
||||
@@ -319,7 +322,7 @@ public partial class TopLevelCommandManager : ObservableObject,
|
||||
try
|
||||
{
|
||||
await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
return new CommandProviderWrapper(extension, _taskScheduler);
|
||||
return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.UI;
|
||||
/// <summary>
|
||||
/// Provides application-specific behavior to supplement the default Application class.
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
public partial class App : Application, IDisposable
|
||||
{
|
||||
private readonly GlobalErrorHandler _globalErrorHandler = new();
|
||||
|
||||
@@ -67,7 +67,7 @@ public partial class App : Application
|
||||
public App()
|
||||
{
|
||||
#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER
|
||||
_globalErrorHandler.Register(this);
|
||||
_globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default);
|
||||
#endif
|
||||
|
||||
Services = ConfigureServices();
|
||||
@@ -178,6 +178,7 @@ public partial class App : Application
|
||||
services.AddSingleton(state);
|
||||
|
||||
// Services
|
||||
services.AddSingleton<ICommandProviderCache, DefaultCommandProviderCache>();
|
||||
services.AddSingleton<TopLevelCommandManager>();
|
||||
services.AddSingleton<AliasManager>();
|
||||
services.AddSingleton<HotkeyManager>();
|
||||
@@ -203,4 +204,11 @@ public partial class App : Application
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_globalErrorHandler.Dispose();
|
||||
EtwTrace.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -128,7 +128,7 @@ public sealed partial class CommandBar : UserControl,
|
||||
|
||||
private void SettingsIcon_Clicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
}
|
||||
|
||||
private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e)
|
||||
|
||||
@@ -203,6 +203,12 @@
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- More section -->
|
||||
<TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="More" />
|
||||
<Border>
|
||||
<Button Command="{x:Bind ViewModel.OpenInternalToolsCommand}" Content="Open internal tools" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Reports;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
@@ -15,14 +15,22 @@ namespace Microsoft.CmdPal.UI.Helpers;
|
||||
/// <summary>
|
||||
/// Global error handler for Command Palette.
|
||||
/// </summary>
|
||||
internal sealed partial class GlobalErrorHandler
|
||||
internal sealed partial class GlobalErrorHandler : IDisposable
|
||||
{
|
||||
private readonly ErrorReportBuilder _errorReportBuilder = new();
|
||||
private Options? _options;
|
||||
private App? _app;
|
||||
|
||||
// GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
|
||||
internal void Register(App app)
|
||||
internal void Register(App app, Options options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
app.UnhandledException += App_UnhandledException;
|
||||
_options = options;
|
||||
|
||||
_app = app;
|
||||
_app.UnhandledException += App_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
}
|
||||
@@ -54,21 +62,15 @@ internal sealed partial class GlobalErrorHandler
|
||||
HandleException(e.Exception, Context.UnobservedTaskException);
|
||||
}
|
||||
|
||||
private static void HandleException(Exception ex, Context context)
|
||||
private void HandleException(Exception ex, Context context)
|
||||
{
|
||||
Logger.LogError($"Unhandled exception detected ({context})", ex);
|
||||
|
||||
if (context == Context.MainThreadException)
|
||||
{
|
||||
var error = DiagnosticsHelper.BuildExceptionMessage(ex, null);
|
||||
var report = $"""
|
||||
This is an error report generated by Windows Command Palette.
|
||||
If you are seeing this message, it means the application has encountered an unexpected issue.
|
||||
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
|
||||
{error}
|
||||
""";
|
||||
var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
|
||||
|
||||
StoreReport(report, storeOnDesktop: false);
|
||||
StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);
|
||||
|
||||
string message;
|
||||
string caption;
|
||||
@@ -138,6 +140,13 @@ internal sealed partial class GlobalErrorHandler
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_app?.UnhandledException -= App_UnhandledException;
|
||||
TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
|
||||
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
|
||||
}
|
||||
|
||||
private enum Context
|
||||
{
|
||||
Unknown = 0,
|
||||
@@ -146,4 +155,26 @@ internal sealed partial class GlobalErrorHandler
|
||||
UnobservedTaskException,
|
||||
AppDomainUnhandledException,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions
|
||||
/// (what to log, what to show to the user, and where to store reports).
|
||||
/// </summary>
|
||||
internal sealed record Options
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default configuration.
|
||||
/// </summary>
|
||||
public static Options Default { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports.
|
||||
/// </summary>
|
||||
public bool RedactPii { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory.
|
||||
/// </summary>
|
||||
public bool StoreReportOnUserDesktop { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ internal sealed partial class TrayIconService
|
||||
{
|
||||
if (wParam == PInvoke.WM_USER + 1)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
|
||||
}
|
||||
else if (wParam == PInvoke.WM_USER + 2)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Windows.Graphics;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.HiDpi;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal static class WindowPositionHelper
|
||||
{
|
||||
private const int DefaultWidth = 800;
|
||||
private const int DefaultHeight = 480;
|
||||
private const int MinimumVisibleSize = 100;
|
||||
private const int DefaultDpi = 96;
|
||||
|
||||
public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi)
|
||||
{
|
||||
if (displayArea is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetDpi = GetDpiForDisplay(displayArea);
|
||||
var predictedSize = ScaleSize(windowSize, windowDpi, targetDpi);
|
||||
|
||||
// Clamp to work area
|
||||
var width = Math.Min(predictedSize.Width, workArea.Width);
|
||||
var height = Math.Min(predictedSize.Height, workArea.Height);
|
||||
|
||||
return new PointInt32(
|
||||
workArea.X + ((workArea.Width - width) / 2),
|
||||
workArea.Y + ((workArea.Height - height) / 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adjusts a saved window rect to ensure it's visible on the nearest display,
|
||||
/// accounting for DPI changes and work area differences.
|
||||
/// </summary>
|
||||
///
|
||||
public static RectInt32 AdjustRectForVisibility(RectInt32 savedRect, SizeInt32 savedScreenSize, int savedDpi)
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest);
|
||||
if (displayArea is null)
|
||||
{
|
||||
return savedRect;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
return savedRect;
|
||||
}
|
||||
|
||||
var targetDpi = GetDpiForDisplay(displayArea);
|
||||
if (savedDpi <= 0)
|
||||
{
|
||||
savedDpi = targetDpi;
|
||||
}
|
||||
|
||||
var hasInvalidSize = savedRect.Width <= 0 || savedRect.Height <= 0;
|
||||
if (hasInvalidSize)
|
||||
{
|
||||
savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight };
|
||||
}
|
||||
|
||||
if (targetDpi != savedDpi)
|
||||
{
|
||||
savedRect = ScaleRect(savedRect, savedDpi, targetDpi);
|
||||
}
|
||||
|
||||
var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea);
|
||||
|
||||
var shouldRecenter = hasInvalidSize ||
|
||||
IsOffscreen(savedRect, workArea) ||
|
||||
savedScreenSize.Width != workArea.Width ||
|
||||
savedScreenSize.Height != workArea.Height;
|
||||
|
||||
if (shouldRecenter)
|
||||
{
|
||||
return CenterRectInWorkArea(clampedSize, workArea);
|
||||
}
|
||||
|
||||
return new RectInt32(savedRect.X, savedRect.Y, clampedSize.Width, clampedSize.Height);
|
||||
}
|
||||
|
||||
private static int GetDpiForDisplay(DisplayArea displayArea)
|
||||
{
|
||||
var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
|
||||
if (hMonitor == IntPtr.Zero)
|
||||
{
|
||||
return DefaultDpi;
|
||||
}
|
||||
|
||||
var hr = PInvoke.GetDpiForMonitor(
|
||||
new HMONITOR(hMonitor),
|
||||
MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI,
|
||||
out var dpiX,
|
||||
out _);
|
||||
|
||||
return hr.Succeeded && dpiX > 0 ? (int)dpiX : DefaultDpi;
|
||||
}
|
||||
|
||||
private static SizeInt32 ScaleSize(SizeInt32 size, int fromDpi, int toDpi)
|
||||
{
|
||||
if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi)
|
||||
{
|
||||
return size;
|
||||
}
|
||||
|
||||
var scale = (double)toDpi / fromDpi;
|
||||
return new SizeInt32(
|
||||
(int)Math.Round(size.Width * scale),
|
||||
(int)Math.Round(size.Height * scale));
|
||||
}
|
||||
|
||||
private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi)
|
||||
{
|
||||
var scale = (double)toDpi / fromDpi;
|
||||
return new RectInt32(
|
||||
(int)Math.Round(rect.X * scale),
|
||||
(int)Math.Round(rect.Y * scale),
|
||||
(int)Math.Round(rect.Width * scale),
|
||||
(int)Math.Round(rect.Height * scale));
|
||||
}
|
||||
|
||||
private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) =>
|
||||
new(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height));
|
||||
|
||||
private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) =>
|
||||
new(
|
||||
workArea.X + ((workArea.Width - size.Width) / 2),
|
||||
workArea.Y + ((workArea.Height - size.Height) / 2),
|
||||
size.Width,
|
||||
size.Height);
|
||||
|
||||
private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) =>
|
||||
rect.X + MinimumVisibleSize > workArea.X + workArea.Width ||
|
||||
rect.X + rect.Width - MinimumVisibleSize < workArea.X ||
|
||||
rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height ||
|
||||
rect.Y + rect.Height - MinimumVisibleSize < workArea.Y;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Composition.SystemBackdrops;
|
||||
using Microsoft.UI.Input;
|
||||
@@ -32,13 +31,9 @@ using Windows.ApplicationModel.Activation;
|
||||
using Windows.Foundation;
|
||||
using Windows.Graphics;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
using Windows.UI.WindowManagement;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.Graphics.Dwm;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.HiDpi;
|
||||
using Windows.Win32.UI.Input.KeyboardAndMouse;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
using WinRT;
|
||||
@@ -60,9 +55,6 @@ public sealed partial class MainWindow : WindowEx,
|
||||
IRecipient<DragCompletedMessage>,
|
||||
IDisposable
|
||||
{
|
||||
private const int DefaultWidth = 800;
|
||||
private const int DefaultHeight = 480;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")]
|
||||
private readonly uint WM_TASKBAR_RESTART;
|
||||
@@ -226,39 +218,40 @@ public sealed partial class MainWindow : WindowEx,
|
||||
PositionCentered(displayArea);
|
||||
}
|
||||
|
||||
private void PositionCentered(DisplayArea displayArea)
|
||||
{
|
||||
var position = WindowPositionHelper.CalculateCenteredPosition(
|
||||
displayArea,
|
||||
AppWindow.Size,
|
||||
(int)this.GetDpiForWindow());
|
||||
|
||||
if (position is not null)
|
||||
{
|
||||
// Use Move(), not MoveAndResize(). Windows auto-resizes on DPI change via WM_DPICHANGED;
|
||||
// the helper already accounts for this when calculating the centered position.
|
||||
AppWindow.Move((PointInt32)position);
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreWindowPosition()
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>();
|
||||
if (settings?.LastWindowPosition is not WindowPosition savedPosition)
|
||||
if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition)
|
||||
{
|
||||
PositionCentered();
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedPosition.Width <= 0 || savedPosition.Height <= 0)
|
||||
{
|
||||
PositionCentered();
|
||||
return;
|
||||
}
|
||||
// MoveAndResize is safe here—we're restoring a saved state at startup,
|
||||
// not moving a live window between displays.
|
||||
var newRect = WindowPositionHelper.AdjustRectForVisibility(
|
||||
savedPosition.ToPhysicalWindowRectangle(),
|
||||
new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight),
|
||||
savedPosition.Dpi);
|
||||
|
||||
var newRect = EnsureWindowIsVisible(savedPosition.ToPhysicalWindowRectangle(), new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), savedPosition.Dpi);
|
||||
AppWindow.MoveAndResize(newRect);
|
||||
}
|
||||
|
||||
private void PositionCentered(DisplayArea displayArea)
|
||||
{
|
||||
if (displayArea is not null)
|
||||
{
|
||||
var centeredPosition = AppWindow.Position;
|
||||
centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2;
|
||||
centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2;
|
||||
|
||||
centeredPosition.X += displayArea.WorkArea.X;
|
||||
centeredPosition.Y += displayArea.WorkArea.Y;
|
||||
AppWindow.Move(centeredPosition);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateWindowPositionInMemory()
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary;
|
||||
@@ -352,7 +345,8 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
if (target == MonitorBehavior.ToLast)
|
||||
{
|
||||
var newRect = EnsureWindowIsVisible(_currentWindowPosition.ToPhysicalWindowRectangle(), new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight), _currentWindowPosition.Dpi);
|
||||
var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight);
|
||||
var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi);
|
||||
AppWindow.MoveAndResize(newRect);
|
||||
}
|
||||
else
|
||||
@@ -382,115 +376,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the window rectangle is visible on-screen.
|
||||
/// </summary>
|
||||
/// <param name="windowRect">The window rectangle in physical pixels.</param>
|
||||
/// <param name="originalScreen">The desktop area the window was positioned on.</param>
|
||||
/// <param name="originalDpi">The window's original DPI.</param>
|
||||
/// <returns>
|
||||
/// A window rectangle in physical pixels, moved to the nearest display and resized
|
||||
/// if the DPI has changed.
|
||||
/// </returns>
|
||||
private static RectInt32 EnsureWindowIsVisible(RectInt32 windowRect, SizeInt32 originalScreen, int originalDpi)
|
||||
{
|
||||
var displayArea = DisplayArea.GetFromRect(windowRect, DisplayAreaFallback.Nearest);
|
||||
if (displayArea is null)
|
||||
{
|
||||
return windowRect;
|
||||
}
|
||||
|
||||
var workArea = displayArea.WorkArea;
|
||||
if (workArea.Width <= 0 || workArea.Height <= 0)
|
||||
{
|
||||
// Fallback, nothing reasonable to do
|
||||
return windowRect;
|
||||
}
|
||||
|
||||
var effectiveDpi = GetEffectiveDpiFromDisplayId(displayArea);
|
||||
if (originalDpi <= 0)
|
||||
{
|
||||
originalDpi = effectiveDpi; // use current DPI as baseline (no scaling adjustment needed)
|
||||
}
|
||||
|
||||
var hasInvalidSize = windowRect.Width <= 0 || windowRect.Height <= 0;
|
||||
if (hasInvalidSize)
|
||||
{
|
||||
windowRect = new RectInt32(windowRect.X, windowRect.Y, DefaultWidth, DefaultHeight);
|
||||
}
|
||||
|
||||
// If we have a DPI change, scale the window rectangle accordingly
|
||||
if (effectiveDpi != originalDpi)
|
||||
{
|
||||
var scalingFactor = effectiveDpi / (double)originalDpi;
|
||||
windowRect = new RectInt32(
|
||||
(int)Math.Round(windowRect.X * scalingFactor),
|
||||
(int)Math.Round(windowRect.Y * scalingFactor),
|
||||
(int)Math.Round(windowRect.Width * scalingFactor),
|
||||
(int)Math.Round(windowRect.Height * scalingFactor));
|
||||
}
|
||||
|
||||
var targetWidth = Math.Min(windowRect.Width, workArea.Width);
|
||||
var targetHeight = Math.Min(windowRect.Height, workArea.Height);
|
||||
|
||||
// Ensure at least some minimum visible area (e.g., 100 pixels)
|
||||
// This helps prevent the window from being entirely offscreen, regardless of display scaling.
|
||||
const int minimumVisibleSize = 100;
|
||||
var isOffscreen =
|
||||
windowRect.X + minimumVisibleSize > workArea.X + workArea.Width ||
|
||||
windowRect.X + windowRect.Width - minimumVisibleSize < workArea.X ||
|
||||
windowRect.Y + minimumVisibleSize > workArea.Y + workArea.Height ||
|
||||
windowRect.Y + windowRect.Height - minimumVisibleSize < workArea.Y;
|
||||
|
||||
// if the work area size has changed, re-center the window
|
||||
var workAreaSizeChanged =
|
||||
originalScreen.Width != workArea.Width ||
|
||||
originalScreen.Height != workArea.Height;
|
||||
|
||||
int targetX;
|
||||
int targetY;
|
||||
var recenter = isOffscreen || workAreaSizeChanged || hasInvalidSize;
|
||||
if (recenter)
|
||||
{
|
||||
targetX = workArea.X + ((workArea.Width - targetWidth) / 2);
|
||||
targetY = workArea.Y + ((workArea.Height - targetHeight) / 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
targetX = windowRect.X;
|
||||
targetY = windowRect.Y;
|
||||
}
|
||||
|
||||
return new RectInt32(targetX, targetY, targetWidth, targetHeight);
|
||||
}
|
||||
|
||||
private static int GetEffectiveDpiFromDisplayId(DisplayArea displayArea)
|
||||
{
|
||||
var effectiveDpi = 96;
|
||||
|
||||
var hMonitor = (HMONITOR)Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
|
||||
if (!hMonitor.IsNull)
|
||||
{
|
||||
var hr = PInvoke.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _);
|
||||
if (hr == 0)
|
||||
{
|
||||
effectiveDpi = (int)dpiX;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning($"GetDpiForMonitor failed with HRESULT: 0x{hr.Value:X8} on display {displayArea.DisplayId}");
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveDpi <= 0)
|
||||
{
|
||||
effectiveDpi = 96;
|
||||
}
|
||||
|
||||
return effectiveDpi;
|
||||
}
|
||||
|
||||
private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
{
|
||||
// Leaving a note here, in case we ever need it:
|
||||
// https://github.com/microsoft/microsoft-ui-xaml/issues/6454
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
<None Remove="Pages\Settings\GeneralPage.xaml" />
|
||||
<None Remove="SettingsWindow.xaml" />
|
||||
<None Remove="Settings\AppearancePage.xaml" />
|
||||
<None Remove="Settings\InternalPage.xaml" />
|
||||
<None Remove="ShellPage.xaml" />
|
||||
<None Remove="Styles\Colors.xaml" />
|
||||
<None Remove="Styles\Settings.xaml" />
|
||||
@@ -264,6 +265,11 @@
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Settings\InternalPage.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="Styles\Colors.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
|
||||
@@ -257,11 +257,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
{
|
||||
_ = DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
OpenSettings();
|
||||
OpenSettings(message.SettingsPageTag);
|
||||
});
|
||||
}
|
||||
|
||||
public void OpenSettings()
|
||||
public void OpenSettings(string pageTag)
|
||||
{
|
||||
if (_settingsWindow is null)
|
||||
{
|
||||
@@ -270,6 +270,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
_settingsWindow.Activate();
|
||||
_settingsWindow.BringToFront();
|
||||
_settingsWindow.Navigate(pageTag);
|
||||
}
|
||||
|
||||
public void Receive(ShowDetailsMessage message)
|
||||
|
||||
@@ -229,12 +229,26 @@
|
||||
<controls:SettingsCard.HeaderIcon>
|
||||
<cpcontrols:ContentIcon>
|
||||
<cpcontrols:ContentIcon.Content>
|
||||
<cpcontrols:IconBox
|
||||
Width="20"
|
||||
Height="20"
|
||||
<controls:SwitchPresenter
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
TargetType="x:Boolean"
|
||||
Value="{x:Bind Icon.IsSet, FallbackValue=x:False, Mode=OneWay}">
|
||||
<controls:Case Value="True">
|
||||
<cpcontrols:IconBox
|
||||
Width="20"
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" />
|
||||
</controls:Case>
|
||||
<controls:Case Value="False">
|
||||
<Image
|
||||
Width="20"
|
||||
Height="20"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Source="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" />
|
||||
</controls:Case>
|
||||
</controls:SwitchPresenter>
|
||||
</cpcontrols:ContentIcon.Content>
|
||||
</cpcontrols:ContentIcon>
|
||||
</controls:SettingsCard.HeaderIcon>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Settings;
|
||||
|
||||
public partial class InternalPage
|
||||
{
|
||||
internal static class SampleData
|
||||
{
|
||||
internal static string ExceptionMessageWithPii { get; } =
|
||||
$"""
|
||||
Test exception with personal information; thrown from the UI thread
|
||||
|
||||
Here is e-mail address <jane.doe@contoso.com>
|
||||
IPv4 address: 192.168.100.1
|
||||
IPv4 loopback address: 127.0.0.1
|
||||
MAC address: 00-14-22-01-23-45
|
||||
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
||||
IPv6 loopback address: ::1
|
||||
Password: P@ssw0rd123!
|
||||
Password=secret
|
||||
Api key: 1234567890abcdef
|
||||
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
|
||||
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
|
||||
X-API-key: 1234567890abcdef
|
||||
Pet-Shop-Subscription-Key: 1234567890abcdef
|
||||
Here is a user name {Environment.UserName}
|
||||
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\Pictures
|
||||
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
|
||||
Here is machine name {Environment.MachineName}
|
||||
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
|
||||
User email john.doe@company.com failed validation
|
||||
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
|
||||
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
|
||||
Phone number 555-123-4567 is invalid
|
||||
API key abc123def456ghi789jkl012mno345pqr678 expired
|
||||
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
|
||||
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
|
||||
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
|
||||
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
|
||||
Email service error: mailto:admin@internal-company.com?subject=Alert
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Page
|
||||
x:Class="Microsoft.CmdPal.UI.Settings.InternalPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<Grid Padding="16">
|
||||
<StackPanel
|
||||
MaxWidth="1000"
|
||||
HorizontalAlignment="Stretch"
|
||||
Spacing="{StaticResource SettingsCardSpacing}">
|
||||
|
||||
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="Tools on this page are for internal use only. This page is not visible in CI builds." />
|
||||
|
||||
<!-- Exception Handling Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Exception Handling" />
|
||||
<controls:SettingsExpander
|
||||
Description="Actions for testing global exception handling from the application"
|
||||
Header="Throw exceptions"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<controls:SettingsExpander.Items>
|
||||
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread">
|
||||
<Button Click="ThrowPlainMainThreadException_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread (with PII)">
|
||||
<Button Click="ThrowPlainMainThreadExceptionPii_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard Description="Throw with delay, when the task is collected by the GC" Header="Throw unobserved exception from a task">
|
||||
<Button Click="ThrowExceptionInUnobservedTask_Click" Content="Throw" />
|
||||
</controls:SettingsCard>
|
||||
</controls:SettingsExpander.Items>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<!-- Diagnostics Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Diagnostics" />
|
||||
<controls:SettingsCard
|
||||
x:Name="LogsSettingsCard"
|
||||
Header="Logs folder"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenLogsCardClicked" Content="Open folder" />
|
||||
</controls:SettingsCard>
|
||||
<controls:SettingsCard
|
||||
x:Name="CurrentLogFileSettingsCard"
|
||||
Header="Current log file"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenCurrentLogCardClicked" Content="Open log" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<!-- Data Section -->
|
||||
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" />
|
||||
<controls:SettingsCard
|
||||
x:Name="ConfigurationFolderSettingsCard"
|
||||
Header="Configuration folder"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<Button Click="OpenConfigFolderCardClick" Content="Open folder" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Page>
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.System;
|
||||
using Page = Microsoft.UI.Xaml.Controls.Page;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// An empty page that can be used on its own or navigated to within a Frame.
|
||||
/// </summary>
|
||||
public sealed partial class InternalPage : Page
|
||||
{
|
||||
public InternalPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from the UI thread");
|
||||
throw new NotImplementedException("Test exception; thrown from the UI thread");
|
||||
}
|
||||
|
||||
private void ThrowExceptionInUnobservedTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Starting a task that will throw test exception");
|
||||
Task.Run(() =>
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from a task");
|
||||
throw new InvalidOperationException("Test exception; thrown from a task");
|
||||
});
|
||||
}
|
||||
|
||||
private void ThrowPlainMainThreadExceptionPii_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Logger.LogDebug("Throwing test exception from the UI thread (PII)");
|
||||
throw new InvalidOperationException(SampleData.ExceptionMessageWithPii);
|
||||
}
|
||||
|
||||
private async void OpenLogsCardClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFolderPath = Logger.CurrentVersionLogDirectoryPath;
|
||||
if (Directory.Exists(logFolderPath))
|
||||
{
|
||||
await Launcher.LaunchFolderPathAsync(logFolderPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open directory in Explorer", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenCurrentLogCardClicked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logPath = Logger.CurrentLogFile;
|
||||
if (File.Exists(logPath))
|
||||
{
|
||||
await Launcher.LaunchUriAsync(new Uri(logPath));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open log file", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OpenConfigFolderCardClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
await Launcher.LaunchFolderPathAsync(directory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to open directory in Explorer", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@
|
||||
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
Tag="Extensions" />
|
||||
<!-- "Internal Tools" page item is added dynamically from code -->
|
||||
</NavigationView.MenuItems>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
|
||||
@@ -30,6 +30,8 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
{
|
||||
private readonly LocalKeyboardListener _localKeyboardListener;
|
||||
|
||||
private readonly NavigationViewItem? _internalNavItem;
|
||||
|
||||
public ObservableCollection<Crumb> BreadCrumbs { get; } = [];
|
||||
|
||||
// Gets or sets optional action invoked after NavigationView is loaded.
|
||||
@@ -54,6 +56,23 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
_localKeyboardListener.Start();
|
||||
Closed += SettingsWindow_Closed;
|
||||
RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true);
|
||||
|
||||
if (!BuildInfo.IsCiBuild)
|
||||
{
|
||||
_internalNavItem = new NavigationViewItem
|
||||
{
|
||||
Content = "Internal Tools",
|
||||
Icon = new FontIcon { Glyph = "\uEC7A" },
|
||||
Tag = "Internal",
|
||||
};
|
||||
NavView.MenuItems.Add(_internalNavItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
_internalNavItem = null;
|
||||
}
|
||||
|
||||
Navigate("General");
|
||||
}
|
||||
|
||||
private void SettingsWindow_Closed(object sender, WindowEventArgs args)
|
||||
@@ -68,9 +87,6 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
// Delay necessary to ensure NavigationView visual state can match navigation
|
||||
Task.Delay(500).ContinueWith(_ => this.NavigationViewLoaded?.Invoke(), TaskScheduler.FromCurrentSynchronizationContext());
|
||||
|
||||
NavView.SelectedItem = NavView.MenuItems[0];
|
||||
Navigate("General");
|
||||
|
||||
if (sender is NavigationView navigationView)
|
||||
{
|
||||
// Register for pane open/close changes to announce to screen readers
|
||||
@@ -96,15 +112,33 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
Navigate((selectedItem.Tag as string)!);
|
||||
}
|
||||
|
||||
private void Navigate(string page)
|
||||
internal void Navigate(string page)
|
||||
{
|
||||
var pageType = page switch
|
||||
Type? pageType;
|
||||
switch (page)
|
||||
{
|
||||
"General" => typeof(GeneralPage),
|
||||
"Appearance" => typeof(AppearancePage),
|
||||
"Extensions" => typeof(ExtensionsPage),
|
||||
_ => null,
|
||||
};
|
||||
case "General":
|
||||
pageType = typeof(GeneralPage);
|
||||
break;
|
||||
case "Appearance":
|
||||
pageType = typeof(AppearancePage);
|
||||
break;
|
||||
case "Extensions":
|
||||
pageType = typeof(ExtensionsPage);
|
||||
break;
|
||||
case "Internal":
|
||||
pageType = typeof(InternalPage);
|
||||
break;
|
||||
case "":
|
||||
// intentional no-op: empty tag means no navigation
|
||||
pageType = null;
|
||||
break;
|
||||
default:
|
||||
// unknown page, no-op and log
|
||||
pageType = null;
|
||||
Logger.LogError($"Unknown settings page tag '{page}'");
|
||||
break;
|
||||
}
|
||||
|
||||
if (pageType is not null)
|
||||
{
|
||||
@@ -268,6 +302,12 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
BreadCrumbs.Add(new(extensionsPageType, extensionsPageType));
|
||||
BreadCrumbs.Add(new(vm.DisplayName, vm));
|
||||
}
|
||||
else if (e.SourcePageType == typeof(InternalPage) && _internalNavItem is not null)
|
||||
{
|
||||
NavView.SelectedItem = _internalNavItem;
|
||||
var pageType = "Internal";
|
||||
BreadCrumbs.Add(new(pageType, pageType));
|
||||
}
|
||||
else
|
||||
{
|
||||
BreadCrumbs.Add(new($"[{e.SourcePageType?.Name}]", string.Empty));
|
||||
|
||||
@@ -8,8 +8,10 @@ using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.UI.Helpers;
|
||||
using Microsoft.CmdPal.UI.Messages;
|
||||
using Microsoft.UI;
|
||||
using Windows.System;
|
||||
using Windows.UI;
|
||||
@@ -99,6 +101,12 @@ internal sealed partial class DevRibbonViewModel : ObservableObject
|
||||
LatestLogs.Clear();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInternalTools()
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Internal"));
|
||||
}
|
||||
|
||||
private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener
|
||||
{
|
||||
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
global using System.Linq;
|
||||
global using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Microsoft.CmdPal.Common.UnitTests</RootNamespace>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class ConnectionStringRuleProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(1, ruleList.Count);
|
||||
Assert.AreEqual("Connection string parameters", ruleList[0].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Server=localhost;Database=mydb;User ID=admin;Password=secret123", "Server=[REDACTED];Database=[REDACTED];User ID=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("Data Source=server.example.com;Initial Catalog=testdb;Uid=user;Pwd=pass", "Data Source=[REDACTED];Initial Catalog=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED]")]
|
||||
[DataRow("Server=localhost;Password=my_secret", "Server=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("No connection string here", "No connection string here")]
|
||||
public void ConnectionStringRules_ShouldMaskConnectionStringParameters(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Password=\"complexPassword123!\"", "Password=[REDACTED]")]
|
||||
[DataRow("Password='myPassword'", "Password=[REDACTED]")]
|
||||
[DataRow("Password=unquotedSecret", "Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleQuotedAndUnquotedValues(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("SERVER=server1;PASSWORD=pass1", "SERVER=[REDACTED];PASSWORD=[REDACTED]")]
|
||||
[DataRow("server=server1;password=pass1", "server=[REDACTED];password=[REDACTED]")]
|
||||
[DataRow("Server=server1;Password=pass1", "Server=[REDACTED];Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldBeCaseInsensitive(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("User ID=admin;Username=john;Password=secret", "User ID=[REDACTED];Username=[REDACTED];Password=[REDACTED]")]
|
||||
[DataRow("Database=mydb;Uid=user1;Pwd=pass1;Server=localhost", "Database=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED];Server=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleMultipleParameters(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Server = localhost ; Password = secret123", "Server=[REDACTED] ; Password=[REDACTED]")]
|
||||
[DataRow("Initial Catalog=db; User ID=admin; Password=pass", "Initial Catalog=[REDACTED]; User ID=[REDACTED]; Password=[REDACTED]")]
|
||||
public void ConnectionStringRules_ShouldHandleWhitespace(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new ConnectionStringRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
public partial class ErrorReportSanitizerTests
|
||||
{
|
||||
private static class TestData
|
||||
{
|
||||
internal static string Input =>
|
||||
$"""
|
||||
HRESULT: 0x80004005
|
||||
HRESULT: -2147467259
|
||||
|
||||
Here is e-mail address <jane.doe@contoso.com>
|
||||
IPv4 address: 192.168.100.1
|
||||
IPv4 loopback address: 127.0.0.1
|
||||
MAC address: 00-14-22-01-23-45
|
||||
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
||||
IPv6 loopback address: ::1
|
||||
Password: P@ssw0rd123!
|
||||
Password=secret
|
||||
Api key: 1234567890abcdef
|
||||
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
|
||||
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
|
||||
X-API-key: 1234567890abcdef
|
||||
Pet-Shop-Subscription-Key: 1234567890abcdef
|
||||
Here is a user name {Environment.UserName}
|
||||
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\RandomFolder
|
||||
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
|
||||
Here is machine name {Environment.MachineName}
|
||||
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
|
||||
User email john.doe@company.com failed validation
|
||||
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
|
||||
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
|
||||
Phone number 555-123-4567 is invalid
|
||||
API key abc123def456ghi789jkl012mno345pqr678 expired
|
||||
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
|
||||
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
|
||||
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
|
||||
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
|
||||
Email service error: mailto:admin@internal-company.com?subject=Alert
|
||||
""";
|
||||
|
||||
public const string Expected =
|
||||
$"""
|
||||
HRESULT: 0x80004005
|
||||
HRESULT: -2147467259
|
||||
|
||||
Here is e-mail address <[EMAIL_REDACTED]>
|
||||
IPv4 address: [IP4_REDACTED]
|
||||
IPv4 loopback address: [IP4_REDACTED]
|
||||
MAC address: [MAC_ADDRESS_REDACTED]
|
||||
IPv6 address: [IP6_REDACTED]
|
||||
IPv6 loopback address: [IP6_REDACTED]
|
||||
Password: [REDACTED]
|
||||
Password= [REDACTED]
|
||||
Api key: [REDACTED]
|
||||
PostgreSQL connection string: [REDACTED]
|
||||
InstrumentationKey= [REDACTED]
|
||||
X-API-key: [REDACTED]
|
||||
Pet-Shop-Subscription-Key: [REDACTED]
|
||||
Here is a user name [USERNAME_REDACTED]
|
||||
And here is a profile path [USER_PROFILE_DIR]RandomFolder
|
||||
Here is a local app data path [LOCALAPPLICATIONDATA_DIR]Microsoft\PowerToys\CmdPal
|
||||
Here is machine name [MACHINE_NAME_REDACTED]
|
||||
JWT token: [REDACTED]
|
||||
User email [EMAIL_REDACTED] failed validation
|
||||
File not found: [MYDOCUMENTS_DIR]se****.txt
|
||||
Connection string: [REDACTED] ID=[REDACTED];Password= [REDACTED]
|
||||
Phone number [PHONE_REDACTED] is invalid
|
||||
API key [TOKEN_REDACTED] expired
|
||||
Failed to connect to [URL_REDACTED]
|
||||
Error accessing [URL_REDACTED]
|
||||
JDBC connection failed: [URL_REDACTED]
|
||||
FTP upload error: [URL_REDACTED]
|
||||
Email service error: mailto:[EMAIL_REDACTED]?subject=Alert
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public partial class ErrorReportSanitizerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Sanitize_ShouldMaskPiiInErrorReport()
|
||||
{
|
||||
// Arrange
|
||||
var reportSanitizer = new ErrorReportSanitizer();
|
||||
var input = TestData.Input;
|
||||
|
||||
// Act
|
||||
var result = reportSanitizer.Sanitize(input);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(TestData.Expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class PiiRuleProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(4, ruleList.Count);
|
||||
Assert.AreEqual("Email addresses", ruleList[0].Description);
|
||||
Assert.AreEqual("Social Security Numbers", ruleList[1].Description);
|
||||
Assert.AreEqual("Credit card numbers", ruleList[2].Description);
|
||||
Assert.AreEqual("Phone numbers", ruleList[3].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Contact me at john.doe@contoso.com", "Contact me at [EMAIL_REDACTED]")]
|
||||
[DataRow("Contact me at a_b-c%2@foo-bar.example.co.uk", "Contact me at [EMAIL_REDACTED]")]
|
||||
[DataRow("My email is john@sub-domain.contoso.com.", "My email is [EMAIL_REDACTED].")]
|
||||
[DataRow("Two: a@b.com and c@d.org", "Two: [EMAIL_REDACTED] and [EMAIL_REDACTED]")]
|
||||
[DataRow("No email here", "No email here")]
|
||||
public void EmailRules_ShouldMaskEmailAddresses(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Call me at 123-456-7890", "Call me at [PHONE_REDACTED]")]
|
||||
[DataRow("My number is (123) 456-7890.", "My number is [PHONE_REDACTED].")]
|
||||
[DataRow("Office: +1 123 456 7890", "Office: [PHONE_REDACTED]")]
|
||||
[DataRow("Two numbers: 123-456-7890 and +420 777123456", "Two numbers: [PHONE_REDACTED] and [PHONE_REDACTED]")]
|
||||
[DataRow("Czech phone +420 777 123 456", "Czech phone [PHONE_REDACTED]")]
|
||||
[DataRow("Slovak phone +421 777 12 34 56", "Slovak phone [PHONE_REDACTED]")]
|
||||
[DataRow("No phone number here", "No phone number here")]
|
||||
public void PhoneRules_ShouldMaskPhoneNumbers(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("My SSN is 123-45-6789", "My SSN is [SSN_REDACTED]")]
|
||||
[DataRow("No SSN here", "No SSN here")]
|
||||
public void SsnRules_ShouldMaskSsn(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("My credit card number is 1234-5678-9012-3456", "My credit card number is [CARD_REDACTED]")]
|
||||
[DataRow("My credit card number is 1234567890123456", "My credit card number is [CARD_REDACTED]")]
|
||||
[DataRow("No credit card here", "No credit card here")]
|
||||
public void CreditCardRules_ShouldMaskCreditCardNumbers(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("Error code: 0x80070005", "Error code: 0x80070005")]
|
||||
[DataRow("Error code: -2147467262", "Error code: -2147467262")]
|
||||
[DataRow("GUID: 123e4567-e89b-12d3-a456-426614174000", "GUID: 123e4567-e89b-12d3-a456-426614174000")]
|
||||
[DataRow("Timestamp: 2023-10-05T14:32:10Z", "Timestamp: 2023-10-05T14:32:10Z")]
|
||||
[DataRow("Version: 1.2.3", "Version: 1.2.3")]
|
||||
[DataRow("Version: 10.0.22631.3448", "Version: 10.0.22631.3448")]
|
||||
[DataRow("MAC: 00:1A:2B:3C:4D:5E", "MAC: 00:1A:2B:3C:4D:5E")]
|
||||
[DataRow("Date: 2023-10-05", "Date: 2023-10-05")]
|
||||
[DataRow("Date: 05/10/2023", "Date: 05/10/2023")]
|
||||
public void PiiRuleProvider_ShouldNotOverRedact(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new PiiRuleProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
|
||||
|
||||
[TestClass]
|
||||
public class SecretKeyValueRulesProviderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetRules_ShouldReturnExpectedRules()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var rules = provider.GetRules();
|
||||
|
||||
// Assert
|
||||
var ruleList = new List<SanitizationRule>(rules);
|
||||
Assert.AreEqual(1, ruleList.Count);
|
||||
Assert.AreEqual("Sensitive key/value pairs", ruleList[0].Description);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=secret123", "password= [REDACTED]")]
|
||||
[DataRow("passphrase=myPassphrase", "passphrase= [REDACTED]")]
|
||||
[DataRow("pwd=test", "pwd= [REDACTED]")]
|
||||
[DataRow("passwd=pass1234", "passwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskPasswordSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("token=abc123def456", "token= [REDACTED]")]
|
||||
[DataRow("access_token=token_value", "access_token= [REDACTED]")]
|
||||
[DataRow("refresh-token=refresh_value", "refresh-token= [REDACTED]")]
|
||||
[DataRow("id token=id_token_value", "id token= [REDACTED]")]
|
||||
[DataRow("bearer token=bearer_value", "bearer token= [REDACTED]")]
|
||||
[DataRow("session token=session_value", "session token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskTokens(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("api key=my_api_key", "api key= [REDACTED]")]
|
||||
[DataRow("api-key=key123", "api-key= [REDACTED]")]
|
||||
[DataRow("api_key=secret_key", "api_key= [REDACTED]")]
|
||||
[DataRow("x-api-key=api123", "x-api-key= [REDACTED]")]
|
||||
[DataRow("x api key=key456", "x api key= [REDACTED]")]
|
||||
[DataRow("client id=client123", "client id= [REDACTED]")]
|
||||
[DataRow("client-secret=secret123", "client-secret= [REDACTED]")]
|
||||
[DataRow("consumer secret=secret456", "consumer secret= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskApiCredentials(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("subscription key=sub_key_123", "subscription key= [REDACTED]")]
|
||||
[DataRow("instrumentation key=instr_key", "instrumentation key= [REDACTED]")]
|
||||
[DataRow("account key=account123", "account key= [REDACTED]")]
|
||||
[DataRow("storage account key=storage_key", "storage account key= [REDACTED]")]
|
||||
[DataRow("shared access key=sak123", "shared access key= [REDACTED]")]
|
||||
[DataRow("SAS token=sas123", "SAS token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskCloudPlatformKeys(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("connection string=Server=localhost;Pwd=pass", "connection string= [REDACTED]")]
|
||||
[DataRow("conn string=conn_value", "conn string= [REDACTED]")]
|
||||
[DataRow("storage connection string=connection_value", "storage connection string= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskConnectionStrings(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("private key=pk123", "private key= [REDACTED]")]
|
||||
[DataRow("certificate password=cert_pass", "certificate password= [REDACTED]")]
|
||||
[DataRow("client certificate password=cert123", "client certificate password= [REDACTED]")]
|
||||
[DataRow("pfx password=pfx_pass", "pfx password= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskCertificateSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("aws access key id=AKIAIOSFODNN7EXAMPLE", "aws access key id= [REDACTED]")]
|
||||
[DataRow("aws secret access key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "aws secret access key= [REDACTED]")]
|
||||
[DataRow("aws session token=session_token_value", "aws session token= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskAwsKeys(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=\"complexPassword123!\"", "password= \"[REDACTED]\"")]
|
||||
[DataRow("api-key='secret-key'", "api-key= '[REDACTED]'")]
|
||||
[DataRow("token=\"bearer_token_value\"", "token= \"[REDACTED]\"")]
|
||||
public void SecretKeyValueRules_ShouldPreserveQuotesAroundRedactedValue(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("PASSWORD=secret", "PASSWORD= [REDACTED]")]
|
||||
[DataRow("Api-Key=key123", "Api-Key= [REDACTED]")]
|
||||
[DataRow("CLIENT_ID=client123", "CLIENT_ID= [REDACTED]")]
|
||||
[DataRow("Pwd=pass123", "Pwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldBeCaseInsensitive(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("regularKey=regularValue", "regularKey=regularValue")]
|
||||
[DataRow("config=myConfig", "config=myConfig")]
|
||||
[DataRow("hostname=server.example.com", "hostname=server.example.com")]
|
||||
[DataRow("port=8080", "port=8080")]
|
||||
public void SecretKeyValueRules_ShouldNotRedactNonSecretKeyValuePairs(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password:secret123", "password: [REDACTED]")]
|
||||
[DataRow("api key:api_key_value", "api key: [REDACTED]")]
|
||||
[DataRow("client_secret:secret_value", "client_secret: [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldSupportColonSeparator(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password = secret123", "password= [REDACTED]")]
|
||||
[DataRow("api key = api_key_value", "api key= [REDACTED]")]
|
||||
[DataRow("token : token_value", "token: [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldHandleWhitespace(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("password=secret API_KEY=key config=myConfig", "password= [REDACTED] API_KEY= [REDACTED] config=myConfig")]
|
||||
[DataRow("client_id=id123 name=admin pwd=pass123", "client_id= [REDACTED] name=admin pwd= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldHandleMultipleKeyValuePairsInSingleString(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("cosmos db key=cosmos_key", "cosmos db key= [REDACTED]")]
|
||||
[DataRow("service principal secret=sp_secret", "service principal secret= [REDACTED]")]
|
||||
[DataRow("shared access signature=sas_signature", "shared access signature= [REDACTED]")]
|
||||
public void SecretKeyValueRules_ShouldMaskServiceSpecificSecrets(string input, string expected)
|
||||
{
|
||||
// Arrange
|
||||
var provider = new SecretKeyValueRulesProvider();
|
||||
|
||||
// Act
|
||||
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.TestUtils;
|
||||
|
||||
/// <summary>
|
||||
/// Test-only helpers for applying SanitizationRule sets without relying on production ITextSanitizer implementation.
|
||||
/// </summary>
|
||||
public static class SanitizerTestHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies the provided rules to the input, in order, mimicking the production sanitizer behavior closely
|
||||
/// but without any external dependencies.
|
||||
/// </summary>
|
||||
public static string ApplyRules(string? input, IEnumerable<SanitizationRule> rules)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return input ?? string.Empty;
|
||||
}
|
||||
|
||||
var result = input;
|
||||
foreach (var rule in rules ?? [])
|
||||
{
|
||||
try
|
||||
{
|
||||
var previous = result;
|
||||
result = rule.Evaluator is null
|
||||
? rule.Regex.Replace(previous, rule.Replacement ?? string.Empty)
|
||||
: rule.Regex.Replace(previous, rule.Evaluator);
|
||||
|
||||
// Guardrail to avoid accidental mass-redaction from a faulty rule
|
||||
if (result.Length < previous.Length * 0.3)
|
||||
{
|
||||
result = previous;
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
// Ignore timeouts in tests
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a lightweight sanitizer instance backed by the given rules.
|
||||
/// Useful when a component expects an ITextSanitizer, but you want deterministic behavior in tests.
|
||||
/// </summary>
|
||||
public static ITextSanitizer CreateSanitizer(IEnumerable<SanitizationRule> rules)
|
||||
=> new InlineSanitizer(rules);
|
||||
|
||||
private sealed class InlineSanitizer : ITextSanitizer
|
||||
{
|
||||
private readonly List<SanitizationRule> _rules;
|
||||
|
||||
public InlineSanitizer(IEnumerable<SanitizationRule> rules)
|
||||
{
|
||||
_rules = rules?.ToList() ?? [];
|
||||
}
|
||||
|
||||
public string Sanitize(string? input) => ApplyRules(input, _rules);
|
||||
|
||||
public void AddRule(string pattern, string replacement, string description = "")
|
||||
{
|
||||
var rx = new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
_rules.Add(new SanitizationRule(rx, replacement, description));
|
||||
}
|
||||
|
||||
public void RemoveRule(string description)
|
||||
{
|
||||
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IReadOnlyList<SanitizationRule> GetRules() => _rules.AsReadOnly();
|
||||
|
||||
public string TestRule(string input, string ruleDescription)
|
||||
{
|
||||
var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase));
|
||||
if (rule.Regex is null)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (rule.Evaluator is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Evaluator);
|
||||
}
|
||||
|
||||
if (rule.Replacement is not null)
|
||||
{
|
||||
return rule.Regex.Replace(input, rule.Replacement);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore exceptions for test determinism
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,13 @@ internal static class Commands
|
||||
var results = new List<IListItem>();
|
||||
results.AddRange(new[]
|
||||
{
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0")))
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0", runWithHiddenWindow: true)))
|
||||
{
|
||||
Title = Resources.Microsoft_plugin_sys_shutdown_computer,
|
||||
Subtitle = Resources.Microsoft_plugin_sys_shutdown_computer_description,
|
||||
Icon = Icons.ShutdownIcon,
|
||||
},
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0")))
|
||||
new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0", runWithHiddenWindow: true)))
|
||||
{
|
||||
Title = Resources.Microsoft_plugin_sys_restart_computer,
|
||||
Subtitle = Resources.Microsoft_plugin_sys_restart_computer_description,
|
||||
|
||||
Reference in New Issue
Block a user