diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs index 5356ddd90d..fecd6ec580 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs @@ -25,6 +25,7 @@ internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider "env", "environment", "manifest", + "log", }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); public IEnumerable GetRules() @@ -61,6 +62,11 @@ internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider return full; } + if (IsVersionSegment(file)) + { + return full; + } + string stem, ext; if (dot > 0 && dot < file.Length - 1) { @@ -106,4 +112,30 @@ internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider var maskedCount = Math.Max(1, stem.Length - keep); return stem[..keep] + new string('*', maskedCount); } + + private static bool IsVersionSegment(string file) + { + var dotIndex = file.IndexOf('.'); + if (dotIndex <= 0 || dotIndex == file.Length - 1) + { + return false; + } + + var hasDot = false; + foreach (var ch in file) + { + if (ch == '.') + { + hasDot = true; + continue; + } + + if (!char.IsDigit(ch)) + { + return false; + } + } + + return hasDot; + } } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs index 4c352ff892..6be99b6ff6 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs @@ -11,7 +11,9 @@ internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider { public IEnumerable GetRules() { - yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses"); + // Disabled for now as these rules can be too aggressive and cause over-sanitization, especially in scenarios like + // error report sanitization where we want to preserve as much useful information as possible while still protecting sensitive data. + // 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"); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs index 964c6d83df..ae8f167f7c 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs @@ -25,52 +25,56 @@ internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider private static partial Regex EmailRx(); [GeneratedRegex(""" - (?xi) - # ---------- boundaries ---------- - (? require separators between blocks (avoid plain big ints) - (?:\(\d{1,4}\)|\d{1,4}) - (?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6} - ) + (?:\(\d{1,4}\)|\d{1,4}) + (?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6} + ) - # ---------- optional extension ---------- - (?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?\d{1,6}))? + # ---------- optional extension ---------- + (?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?\d{1,6}))? - (?!-\w) # don't end just before '-letter'/'-digit' - """, - SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)] + # ---------- end boundary (allow whitespace/newlines at edges) ---------- + (?!-\w) # don't end just before '-letter'/'-digit' + (?!\w) # don't be immediately followed by a word char + """, + SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, + SanitizerDefaults.DefaultMatchTimeoutMs)] private static partial Regex PhoneRx(); [GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b", diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs index 7963aec154..ac0dfddf58 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs @@ -11,37 +11,42 @@ public sealed class WindowPosition /// /// Gets or sets left position in device pixels. /// - public int X { get; set; } + public int X { get; init; } /// /// Gets or sets top position in device pixels. /// - public int Y { get; set; } + public int Y { get; init; } /// /// Gets or sets width in device pixels. /// - public int Width { get; set; } + public int Width { get; init; } /// /// Gets or sets height in device pixels. /// - public int Height { get; set; } + public int Height { get; init; } /// /// Gets or sets width of the screen in device pixels where the window is located. /// - public int ScreenWidth { get; set; } + public int ScreenWidth { get; init; } /// /// Gets or sets height of the screen in device pixels where the window is located. /// - public int ScreenHeight { get; set; } + public int ScreenHeight { get; init; } /// /// Gets or sets DPI (dots per inch) of the display where the window is located. /// - public int Dpi { get; set; } + public int Dpi { get; init; } + + /// + /// Gets a value indicating whether the width and height of the window are valid (greater than 0). + /// + public bool IsSizeValid => Width > 0 && Height > 0; /// /// Converts the window position properties to a structure representing the physical window rectangle. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs index bf8af589a6..766c4bf17c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs @@ -18,7 +18,7 @@ internal static class WindowPositionHelper private const int MinimumVisibleSize = 100; private const int DefaultDpi = 96; - public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi) + public static RectInt32? CenterOnDisplay(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi) { if (displayArea is null) { @@ -32,15 +32,9 @@ internal static class WindowPositionHelper } 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)); + var scaledSize = ScaleSize(windowSize, windowDpi, targetDpi); + var clampedSize = ClampSize(scaledSize.Width, scaledSize.Height, workArea); + return CenterRectInWorkArea(clampedSize, workArea); } /// @@ -74,6 +68,10 @@ internal static class WindowPositionHelper savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight }; } + // Remember the original size before DPI scaling - needed to compute + // gaps relative to the old screen when repositioning across displays. + var originalSize = new SizeInt32(savedRect.Width, savedRect.Height); + if (targetDpi != savedDpi) { savedRect = ScaleRect(savedRect, savedDpi, targetDpi); @@ -81,12 +79,17 @@ internal static class WindowPositionHelper var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea); - var shouldRecenter = hasInvalidSize || - IsOffscreen(savedRect, workArea) || - savedScreenSize.Width != workArea.Width || - savedScreenSize.Height != workArea.Height; + if (hasInvalidSize) + { + return CenterRectInWorkArea(clampedSize, workArea); + } - if (shouldRecenter) + if (savedScreenSize.Width != workArea.Width || savedScreenSize.Height != workArea.Height) + { + return RepositionRelativeToWorkArea(savedRect, savedScreenSize, originalSize, clampedSize, workArea); + } + + if (IsOffscreen(savedRect, workArea)) { return CenterRectInWorkArea(clampedSize, workArea); } @@ -126,27 +129,92 @@ internal static class WindowPositionHelper private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi) { + if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi) + { + return rect; + } + + // Don't scale position, that's absolute coordinates in virtual screen space var scale = (double)toDpi / fromDpi; return new RectInt32( - (int)Math.Round(rect.X * scale), - (int)Math.Round(rect.Y * scale), + rect.X, + rect.Y, (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 SizeInt32 ClampSize(int width, int height, RectInt32 workArea) + { + return new SizeInt32(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height)); + } - private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) => - new( + private static RectInt32 RepositionRelativeToWorkArea(RectInt32 savedRect, SizeInt32 savedScreenSize, SizeInt32 originalSize, SizeInt32 clampedSize, RectInt32 workArea) + { + // Treat each axis as a 3-zone grid (start / center / end) so that + // edge-snapped windows stay snapped and centered windows stay centered. + // We don't store the old work area origin, so we use the current one as a + // best estimate (correct when the same physical display changed resolution/DPI/taskbar). + var newX = ScaleAxisByZone(savedRect.X, originalSize.Width, clampedSize.Width, workArea.X, savedScreenSize.Width, workArea.Width); + var newY = ScaleAxisByZone(savedRect.Y, originalSize.Height, clampedSize.Height, workArea.Y, savedScreenSize.Height, workArea.Height); + + newX = Math.Clamp(newX, workArea.X, Math.Max(workArea.X, workArea.X + workArea.Width - clampedSize.Width)); + newY = Math.Clamp(newY, workArea.Y, Math.Max(workArea.Y, workArea.Y + workArea.Height - clampedSize.Height)); + + return new RectInt32(newX, newY, clampedSize.Width, clampedSize.Height); + } + + /// + /// Repositions a window along one axis using a 3-zone model (start / center / end). + /// The zone is determined by which third of the old screen the window center falls in. + /// Uses (pre-DPI-scaling) for gap calculations against + /// the old screen, and (post-scaling) for placement on the new screen. + /// + private static int ScaleAxisByZone(int savedPos, int oldWindowSize, int newWindowSize, int workAreaOrigin, int oldScreenSize, int newScreenSize) + { + if (oldScreenSize <= 0 || newScreenSize <= 0) + { + return savedPos; + } + + var gapFromStart = savedPos - workAreaOrigin; + var windowCenter = gapFromStart + (oldWindowSize / 2); + + if (windowCenter >= oldScreenSize / 3 && windowCenter <= oldScreenSize * 2 / 3) + { + // Center zone - keep centered + return workAreaOrigin + ((newScreenSize - newWindowSize) / 2); + } + + var gapFromEnd = oldScreenSize - gapFromStart - oldWindowSize; + + if (gapFromStart <= gapFromEnd) + { + // Start zone - preserve proportional distance from start edge + var rel = (double)gapFromStart / oldScreenSize; + return workAreaOrigin + (int)Math.Round(rel * newScreenSize); + } + else + { + // End zone - preserve proportional distance from end edge + var rel = (double)gapFromEnd / oldScreenSize; + return workAreaOrigin + newScreenSize - newWindowSize - (int)Math.Round(rel * newScreenSize); + } + } + + private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) + { + return new RectInt32( 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; + private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) + { + return 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; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 95a4f0c7cb..d4e9387c23 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -74,6 +74,7 @@ public sealed partial class MainWindow : WindowEx, private readonly IThemeService _themeService; private readonly WindowThemeSynchronizer _windowThemeSynchronizer; private bool _ignoreHotKeyWhenFullScreen = true; + private bool _suppressDpiChange; private bool _themeServiceInitialized; // Session tracking for telemetry @@ -129,6 +130,16 @@ public sealed partial class MainWindow : WindowEx, _keyboardListener.SetProcessCommand(new CmdPalKeyboardService.ProcessCommand(HandleSummon)); + WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); + + // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a + // member (and instead like, use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our **WindProc will explode**. + _hotkeyWndProc = HotKeyPrc; + var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); + _originalWndProc = Marshal.GetDelegateForFunctionPointer(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); + this.SetIcon(); AppWindow.Title = RS_.GetString("AppName"); RestoreWindowPosition(); @@ -156,16 +167,6 @@ public sealed partial class MainWindow : WindowEx, SizeChanged += WindowSizeChanged; RootElement.Loaded += RootElementLoaded; - WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); - - // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a - // member (and instead like, use a local), then the pointer we marshal - // into the WindowLongPtr will be useless after we leave this function, - // and our **WindProc will explode**. - _hotkeyWndProc = HotKeyPrc; - var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); - _originalWndProc = Marshal.GetDelegateForFunctionPointer(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); - // Load our settings, and then also wire up a settings changed handler HotReloadSettings(); App.Current.Services.GetService()!.SettingsChanged += SettingsChangedHandler; @@ -216,6 +217,11 @@ public sealed partial class MainWindow : WindowEx, // Now that our content has loaded, we can update our draggable regions UpdateRegionsForCustomTitleBar(); + // Also update regions when DPI changes. SizeChanged only fires when the logical + // (DIP) size changes — a DPI change that scales the physical size while preserving + // the DIP size won't trigger it, leaving drag regions at the old physical coordinates. + RootElement.XamlRoot.Changed += XamlRoot_Changed; + // Add dev ribbon if enabled if (!BuildInfo.IsCiBuild) { @@ -224,6 +230,8 @@ public sealed partial class MainWindow : WindowEx, } } + private void XamlRoot_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateRegionsForCustomTitleBar(); + private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar(); private void PositionCentered() @@ -234,16 +242,14 @@ public sealed partial class MainWindow : WindowEx, private void PositionCentered(DisplayArea displayArea) { - var position = WindowPositionHelper.CalculateCenteredPosition( + var rect = WindowPositionHelper.CenterOnDisplay( displayArea, AppWindow.Size, (int)this.GetDpiForWindow()); - if (position is not null) + if (rect 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); + MoveAndResizeDpiAware(rect.Value); } } @@ -252,29 +258,62 @@ public sealed partial class MainWindow : WindowEx, var settings = App.Current.Services.GetService(); if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition) { + // don't try to restore if the saved position is invalid, just recenter 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); - AppWindow.MoveAndResize(newRect); + MoveAndResizeDpiAware(newRect); + } + + /// + /// Moves and resizes the window while suppressing WM_DPICHANGED. + /// The caller is expected to provide a rect already scaled for the target display's DPI. + /// Without suppression, the framework would apply its own DPI scaling on top, double-scaling the window. + /// + private void MoveAndResizeDpiAware(RectInt32 rect) + { + var originalMinHeight = MinHeight; + var originalMinWidth = MinWidth; + + _suppressDpiChange = true; + + try + { + // WindowEx is uses current DPI to calculate the minimum window size + MinHeight = 0; + MinWidth = 0; + AppWindow.MoveAndResize(rect); + } + finally + { + MinHeight = originalMinHeight; + MinWidth = originalMinWidth; + _suppressDpiChange = false; + } } private void UpdateWindowPositionInMemory() { + var placement = new WINDOWPLACEMENT { length = (uint)Marshal.SizeOf() }; + if (!PInvoke.GetWindowPlacement(_hwnd, ref placement)) + { + return; + } + + var rect = placement.rcNormalPosition; var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary; _currentWindowPosition = new WindowPosition { - X = AppWindow.Position.X, - Y = AppWindow.Position.Y, - Width = AppWindow.Size.Width, - Height = AppWindow.Size.Height, + X = rect.X, + Y = rect.Y, + Width = rect.Width, + Height = rect.Height, Dpi = (int)this.GetDpiForWindow(), ScreenWidth = displayArea.WorkArea.Width, ScreenHeight = displayArea.WorkArea.Height, @@ -468,7 +507,7 @@ public sealed partial class MainWindow : WindowEx, { var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight); var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi); - AppWindow.MoveAndResize(newRect); + MoveAndResizeDpiAware(newRect); } else { @@ -812,18 +851,12 @@ public sealed partial class MainWindow : WindowEx, var settings = serviceProvider.GetService(); if (settings is not null) { - settings.LastWindowPosition = new WindowPosition + // a quick sanity check, so we don't overwrite correct values + if (_currentWindowPosition.IsSizeValid) { - X = _currentWindowPosition.X, - Y = _currentWindowPosition.Y, - Width = _currentWindowPosition.Width, - Height = _currentWindowPosition.Height, - Dpi = _currentWindowPosition.Dpi, - ScreenWidth = _currentWindowPosition.ScreenWidth, - ScreenHeight = _currentWindowPosition.ScreenHeight, - }; - - SettingsModel.SaveSettings(settings); + settings.LastWindowPosition = _currentWindowPosition; + SettingsModel.SaveSettings(settings); + } } var extensionService = serviceProvider.GetService()!; @@ -1184,6 +1217,13 @@ public sealed partial class MainWindow : WindowEx, // Prevent the window from maximizing when double-clicking the title bar area case PInvoke.WM_NCLBUTTONDBLCLK: return (LRESULT)IntPtr.Zero; + + // When restoring a saved position across monitors with different DPIs, + // MoveAndResize already sets the correctly-scaled size. Suppress the + // framework's automatic DPI resize to avoid double-scaling. + case PInvoke.WM_DPICHANGED when _suppressDpiChange: + return (LRESULT)IntPtr.Zero; + case PInvoke.WM_HOTKEY: { var hotkeyIndex = (int)wParam.Value; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt index 562d592076..7d0a2c71f3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt @@ -108,3 +108,7 @@ EVENT_SYSTEM_FOREGROUND WINEVENT_OUTOFCONTEXT GetWindowThreadProcessId AttachThreadInput + +GetWindowPlacement +WINDOWPLACEMENT +WM_DPICHANGED diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs index 6d27172fa2..54c7ba92d7 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs @@ -6,42 +6,42 @@ namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; public partial class ErrorReportSanitizerTests { - private static class TestData + internal static class TestData { internal static string Input => - $""" - HRESULT: 0x80004005 - HRESULT: -2147467259 + $""" + HRESULT: 0x80004005 + HRESULT: -2147467259 - Here is e-mail address - 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 - """; + Here is e-mail address + 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 = $""" @@ -49,8 +49,8 @@ public partial class ErrorReportSanitizerTests HRESULT: -2147467259 Here is e-mail address <[EMAIL_REDACTED]> - IPv4 address: [IP4_REDACTED] - IPv4 loopback address: [IP4_REDACTED] + IPv4 address: 192.168.100.1 + IPv4 loopback address: 127.0.0.1 MAC address: [MAC_ADDRESS_REDACTED] IPv6 address: [IP6_REDACTED] IPv6 loopback address: [IP6_REDACTED] @@ -77,5 +77,55 @@ public partial class ErrorReportSanitizerTests FTP upload error: [URL_REDACTED] Email service error: mailto:[EMAIL_REDACTED]?subject=Alert """; + + internal static string Input2 => + $""" + ============================================================ + Hello World! Command Palette is starting. + + Application: + App version: 0.0.1.0 + Packaging flavor: Packaged + Is elevated: no + + Environment: + OS version: Microsoft Windows 10.0.26220 + OS architecture: X64 + Runtime identifier: win-x64 + Framework: .NET 9.0.13 + Process architecture: X64 + Culture: cs-CZ + UI culture: en-US + + Paths: + Log directory: {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal\Logs\0.0.1.0 + Config directory: {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Packages\Microsoft.CommandPalette.Dev_8wekyb3d8bbwe\LocalState + ============================================================ + """; + + public const string Expected2 = + """ + ============================================================ + Hello World! Command Palette is starting. + + Application: + App version: 0.0.1.0 + Packaging flavor: Packaged + Is elevated: no + + Environment: + OS version: Microsoft Windows 10.0.26220 + OS architecture: X64 + Runtime identifier: win-x64 + Framework: .NET 9.0.13 + Process architecture: X64 + Culture: cs-CZ + UI culture: en-US + + Paths: + Log directory: [LOCALAPPLICATIONDATA_DIR]Microsoft\PowerToys\CmdPal\Logs\0.0.1.0 + Config directory: [LOCALAPPLICATIONDATA_DIR]Packages\Microsoft.CommandPalette.Dev_8wekyb3d8bbwe\LocalState + ============================================================ + """; } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs index 1ab57acd2e..294279b5fc 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs @@ -22,4 +22,18 @@ public partial class ErrorReportSanitizerTests // Assert Assert.AreEqual(TestData.Expected, result); } + + [TestMethod] + public void Sanitize_ShouldNotMaskTooMuchPiiInErrorReport() + { + // Arrange + var reportSanitizer = new ErrorReportSanitizer(); + var input = TestData.Input2; + + // Act + var result = reportSanitizer.Sanitize(input); + + // Assert + Assert.AreEqual(TestData.Expected2, result); + } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/FilenameMaskRuleProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/FilenameMaskRuleProviderTests.cs new file mode 100644 index 0000000000..6c5875fd60 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/FilenameMaskRuleProviderTests.cs @@ -0,0 +1,62 @@ +// 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 FilenameMaskRuleProviderTests +{ + [TestMethod] + public void GetRules_ShouldReturnExpectedRules() + { + // Arrange + var provider = new FilenameMaskRuleProvider(); + + // Act + var rules = provider.GetRules(); + + // Assert + var ruleList = new List(rules); + Assert.AreEqual(1, ruleList.Count); + Assert.AreEqual("Mask filename in any path", ruleList[0].Description); + } + + [DataTestMethod] + [DataRow(@"C:\Users\Alice\Documents\secret.txt", @"C:\Users\Alice\Documents\se****.txt")] + [DataRow(@"logs\error-report.log", @"logs\er**********.log")] + [DataRow(@"/var/logs/trace.json", @"/var/logs/tr***.json")] + public void FilenameRules_ShouldMaskFileNamesInPaths(string input, string expected) + { + // Arrange + var provider = new FilenameMaskRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("C:\\Users\\Alice\\Documents\\", "C:\\Users\\Alice\\Documents\\")] + [DataRow(@"C:\Users\Alice\PowerToys\CmdPal\Logs\1.2.3.4", @"C:\Users\Alice\PowerToys\CmdPal\Logs\1.2.3.4")] + [DataRow(@"C:\Users\Alice\appsettings.json", @"C:\Users\Alice\appsettings.json")] + [DataRow(@"C:\Users\Alice\.env", @"C:\Users\Alice\.env")] + [DataRow(@"logs\readme", @"logs\readme")] + public void FilenameRules_ShouldNotMaskNonSensitivePatterns(string input, string expected) + { + // Arrange + var provider = new FilenameMaskRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs index ac490f5a6b..3f2d3c92e7 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs @@ -54,6 +54,8 @@ public class PiiRuleProviderTests [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("Version 1.2.3.4", "Version 1.2.3.4")] + [DataRow("OS version: Microsoft Windows 10.0.26220", "OS version: Microsoft Windows 10.0.26220")] [DataRow("No phone number here", "No phone number here")] public void PhoneRules_ShouldMaskPhoneNumbers(string input, string expected) { @@ -104,6 +106,8 @@ public class PiiRuleProviderTests [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: 1.2.3.4", "Version: 1.2.3.4")] + [DataRow("Version: 0.2.3.4", "Version: 0.2.3.4")] [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")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs index 1614273d83..40777dd391 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs @@ -2,6 +2,8 @@ // 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; +using ManagedCommon; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; @@ -26,6 +28,22 @@ internal static class BrowserInfoServiceExtensions /// public static bool Open(this IBrowserInfoService browserInfoService, string url) { + // If the URL is a valid URI, attempt to open it with the default browser by invoking it through the shell. + if (Uri.TryCreate(url, UriKind.Absolute, out _)) + { + try + { + ShellHelpers.OpenInShell(url); + return true; + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to launch the URI {url}: {ex}"); + } + } + + // Use legacy method to open the URL if it's not a well-formed URI or if the shell launch fails. + // This may handle cases where the URL is a search query or a custom URI scheme. var defaultBrowser = browserInfoService.GetDefaultBrowser(); return defaultBrowser != null && ShellHelpers.OpenCommandInShell(defaultBrowser.Path, defaultBrowser.ArgumentsPattern, url); }