Compare commits

...

17 Commits

Author SHA1 Message Date
Michael Jolley
09f2cabf47 Fix CmdPal bookmark resolver regression and fallback test
- Preserve the raw command token for the Unknown fallback target in
  BookmarkResolver so normalization no longer trims meaningful trailing
  whitespace (e.g. a quoted single space). Normalization is now applied
  to a separate copy used only for path detection/probing.
- Make MockProcessLauncher mirror the real launcher by failing for a
  filesystem target that does not exist, which is what drives the
  nearest-existing-parent fallback the test asserts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 13:53:40 -05:00
Michael Jolley
5cce6abf20 fix: GreedyFind should only probe whitespace boundaries, not path separators
Probing at path-separator boundaries caused GreedyFind to walk up to
ancestor directories (e.g. C:\Users) for non-existing paths, incorrectly
classifying quoted paths like "C:\Users\John Doe\file.txt" as Directory
instead of letting them fall through to the LooksPathy heuristic which
correctly classifies by extension.

Also simplifies ExpandToBestExistingPath return type (removes vestigial
UsedParentDirectoryFallback bool that was always false).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:11:11 -05:00
Michael Jolley
e040096b59 fix: remove parent-directory fallback from resolver classification
The resolver should classify paths by their extension/existence without
falling back to parent directories. Parent-fallback belongs only in
LaunchBookmarkCommand at launch time. This fixes test failures where
non-existing files with extensions were incorrectly classified as
Directory.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 15:21:08 -05:00
Michael Jolley
eb4b41030d fix(bookmark): add #nullable enable and missing using for test files
- BookmarksCommandProviderTests.cs: add #nullable enable for Classification? usage
- BookmarkResolverGreedyFindTests.cs: add using Microsoft.CmdPal.Ext.Bookmarks.Helpers for CommandKind

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 14:37:52 -05:00
Michael Jolley
60b6bc8c34 fix(bookmark): add missing _processLauncher arg to new BookmarkListItem call
A new call site was added on main that the PR didn't account for.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 13:59:02 -05:00
Michael Jolley
c78723bfde Merge remote-tracking branch 'origin/main' into pr-48712 2026-06-24 13:58:04 -05:00
Michael Jolley
3154e9114f style(bookmark): fix SA1515 comment preceded by blank line
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 12:38:56 -05:00
Michael Jolley
ad69a9b18d style(bookmark): fix SA1507 multiple blank lines violation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 11:02:35 -05:00
Michael Jolley
8e95bd4d75 fix(bookmark): remove .Value on nullable reference type Classification
Classification is a sealed record (reference type), not a struct.
Nullable reference types don't have .Value — access properties directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 10:13:33 -05:00
Michael Jolley
fd672b0460 fix(bookmark): improve PR 48712 code quality
- Remove reflection-based tests; use IProcessLauncher mock to capture
  launched classifications (proper use of the DI that the PR introduced)
- Simplify GreedyFind: replace List/HashSet/Sort allocations with a
  single for-loop that skips non-boundary positions
- Add missing 'using ManagedCommon' to PathHelpers.cs (compile fix)
- Replace bare catch blocks with Logger.LogError calls in
  TryResolvePathCandidateCore and TryPercentDecodePathCandidate

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 23:19:12 -05:00
Hawk
3c37c311a1 test(bookmark): add normalization + GreedyFind unit tests 2026-06-18 19:43:50 -05:00
Hawk
17742ebc19 fix(bookmark): add PathHelpers overload using Classification 2026-06-18 19:43:24 -05:00
Hawk
52f81b23d7 fix(bookmark): consolidate nearest-parent helper and optimize GreedyFind 2026-06-18 19:43:06 -05:00
Hawk
7f263e5a5e refactor(cmdpal): extract PathNormalization helper and add Windows-only tests (#48672)
- Extract NormalizePathForWindows from BookmarkResolver into a dedicated
  internal helper class (Services/PathNormalization.cs).
- Add safe trailing-whitespace trimming (incl. NBSP) so trailing copy/paste
  artifacts don't defeat File.Exists probes; trailing dots are deliberately
  preserved to avoid lossy transformations.
- BookmarkResolver delegates through a thin private wrapper so existing
  call sites and behavior are unchanged.
- Add PathNormalizationTests covering NFC, non-ASCII preservation,
  trailing-whitespace handling, and Windows-only long-path / NT-object
  prefix stripping. Windows-only assertions use Assert.Inconclusive on
  non-Windows agents so CI on Linux/macOS skips them cleanly.
2026-06-17 23:42:04 -05:00
Hawk
5bc35f911e fix(cmdpal): harden bookmark resolver path handling (#48672) 2026-06-17 23:37:46 -05:00
Hawk
6f10aadba2 fix(cmdpal): add safe bookmark launch fallback (#48672) 2026-06-17 23:37:33 -05:00
Hawk
ad06bde492 tests(bookmarks): add bookmark path handling unit tests for accented, spaces, percent-encoding; add TestLaunchOverride hook and launcher fallback for tests (#48672) 2026-06-17 23:34:06 -05:00
13 changed files with 929 additions and 36 deletions

View File

@@ -0,0 +1,187 @@
// 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.
#nullable enable
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Bookmarks.Commands;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
[TestClass]
public class BookmarkPathHandlingTests
{
[TestMethod]
public async Task Accented_NonAscii_Path_Is_Classified_As_Directory()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var accentedName = "Éfolder";
var dir = Path.Combine(tempRoot, accentedName);
Directory.CreateDirectory(dir);
try
{
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
var classification = await resolver.TryClassifyAsync(dir, CancellationToken.None);
Assert.IsTrue(classification.Success, "Classification should succeed for existing accented dir.");
Assert.AreEqual(CommandKind.Directory, classification.Result.Kind);
Assert.AreEqual(Path.GetFullPath(dir), classification.Result.Target, StringComparer.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, true);
}
}
}
[TestMethod]
public async Task Spaces_And_Punctuation_In_Path_Are_Handled()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var folderName = "My Folder, (Test)";
var dir = Path.Combine(tempRoot, folderName);
Directory.CreateDirectory(dir);
try
{
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
var classification = await resolver.TryClassifyAsync(dir, CancellationToken.None);
Assert.IsTrue(classification.Success, "Classification should succeed for existing folder with spaces/punctuation.");
Assert.AreEqual(CommandKind.Directory, classification.Result.Kind);
Assert.AreEqual(Path.GetFullPath(dir), classification.Result.Target, StringComparer.OrdinalIgnoreCase);
// Also verify that percent-encoded input for the same folder (conservative decoding) resolves to same directory if decoder present
var encoded = Path.Combine(Path.GetDirectoryName(dir) ?? string.Empty, folderName.Replace(" ", "%20"));
var classificationEncoded = await resolver.TryClassifyAsync(encoded, CancellationToken.None);
// either decoding is supported and classification succeeds, or it remains unknown; in either case, do not start external processes in this test
if (classificationEncoded.Success)
{
Assert.AreEqual(CommandKind.Directory, classificationEncoded.Result.Kind);
Assert.AreEqual(Path.GetFullPath(dir), classificationEncoded.Result.Target, StringComparer.OrdinalIgnoreCase);
}
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, true);
}
}
}
[TestMethod]
public async Task PercentEncoded_Accented_Input_Decodes_To_Existing_Accented_Path_When_Present()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var accentedName = "Éfolder";
var dir = Path.Combine(tempRoot, accentedName);
Directory.CreateDirectory(dir);
try
{
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
// Percent-encode the UTF8 bytes of the accented name (capital E with acute)
var utf8 = System.Text.Encoding.UTF8.GetBytes(accentedName);
var encodedName = string.Concat(Array.ConvertAll(utf8, b => $"%{b:X2}"));
var encodedPath = Path.Combine(tempRoot, encodedName);
var classification = await resolver.TryClassifyAsync(encodedPath, CancellationToken.None);
// Conservative behavior: if resolver decodes percent-encoding it should resolve to the real folder
if (classification.Success)
{
Assert.AreEqual(CommandKind.Directory, classification.Result.Kind);
Assert.AreEqual(Path.GetFullPath(dir), classification.Result.Target, StringComparer.OrdinalIgnoreCase);
}
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, true);
}
}
}
[TestMethod]
public async Task LaunchBookmark_FallsBack_To_Nearest_Existing_Parent_Directory()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var parent = Path.Combine(tempRoot, "parent");
Directory.CreateDirectory(parent);
var child = Path.Combine(parent, "child", "file.txt"); // child does not exist
try
{
var bookmark = new BookmarkData("TestBookmark", child);
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
var classification = resolver.ClassifyOrUnknown(bookmark.Bookmark);
// Use a mock process launcher to capture what gets launched
var mockLauncher = new MockProcessLauncher();
var launchCmd = new LaunchBookmarkCommand(bookmark, classification, new TestBookmarkIconLocator(), resolver, mockLauncher);
var result = launchCmd.Invoke(sender: this);
// Should have launched a classification targeting the existing parent directory
Assert.IsNotNull(mockLauncher.LastLaunchedClassification, "Expected a launch to be captured");
Assert.AreEqual(CommandKind.Directory, mockLauncher.LastLaunchedClassification.Kind);
Assert.IsTrue(Directory.Exists(parent));
Assert.AreEqual(Path.GetFullPath(parent), mockLauncher.LastLaunchedClassification.Target, StringComparer.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, true);
}
}
}
private sealed class MockProcessLauncher : IProcessLauncher
{
public Classification? LastLaunchedClassification { get; private set; }
public bool Launch(Classification classification, bool runAsAdmin = false)
{
LastLaunchedClassification = classification;
// Mirror the real launcher: ShellExecute fails for a filesystem target that
// doesn't exist, and that failure is what drives the nearest-existing-parent
// fallback. Existing targets (e.g. the parent directory) "launch" successfully.
return Directory.Exists(classification.Target) || File.Exists(classification.Target);
}
}
private sealed class TestBookmarkIconLocator : IBookmarkIconLocator
{
public Task<IIconInfo> GetIconForPath(Classification classification, CancellationToken cancellationToken = default)
{
// Return some default icon quickly; tests don't rely on icon semantics
return Task.FromResult<IIconInfo>(Icons.Reloading);
}
}
}

View File

@@ -0,0 +1,94 @@
// 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.
#nullable enable
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
[TestClass]
public class BookmarkResolverGreedyFindTests
{
[TestMethod]
public async Task Resolver_Handles_Different_Unicode_Normalization_Forms()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var nfcName = "\u00C9folder"; // Éfolder (precomposed)
var nfdName = "E\u0301folder"; // E + combining acute
var dirNfc = Path.Combine(tempRoot, nfcName);
Directory.CreateDirectory(dirNfc);
try
{
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
var inputNfc = dirNfc;
var cNfc = await resolver.TryClassifyAsync(inputNfc, CancellationToken.None);
Assert.IsTrue(cNfc.Success);
Assert.AreEqual(CommandKind.Directory, cNfc.Result.Kind);
Assert.AreEqual(Path.GetFullPath(dirNfc), cNfc.Result.Target, StringComparer.OrdinalIgnoreCase);
var inputNfd = Path.Combine(tempRoot, nfdName);
var cNfd = await resolver.TryClassifyAsync(inputNfd, CancellationToken.None);
Assert.IsTrue(cNfd.Success, "Resolver should succeed for NFD-encoded path if equivalent NFC exists on disk");
Assert.AreEqual(CommandKind.Directory, cNfd.Result.Kind);
Assert.AreEqual(Path.GetFullPath(dirNfc), cNfd.Result.Target, StringComparer.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, true);
}
}
}
[TestMethod]
public async Task GreedyFind_Resolves_With_Mixed_Separators_And_Forward_Slashes()
{
var tempRoot = Path.Combine(Path.GetTempPath(), "CmdPalBookmarkTests", Guid.NewGuid().ToString("N"));
var a = Path.Combine(tempRoot, "A");
var b = Path.Combine(a, "B");
Directory.CreateDirectory(b);
var file = Path.Combine(b, "file.txt");
File.WriteAllText(file, "hello");
try
{
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
// Mixed separators
var mixed = tempRoot + "\\A/B\\file.txt extra args";
var rMixed = await resolver.TryClassifyAsync(mixed, CancellationToken.None);
Assert.IsTrue(rMixed.Success);
Assert.AreEqual(CommandKind.FileDocument, rMixed.Result.Kind);
Assert.AreEqual(Path.GetFullPath(file), rMixed.Result.Target, StringComparer.OrdinalIgnoreCase);
// Forward slashes only
var forward = tempRoot + "/A/B/file.txt";
var rForward = await resolver.TryClassifyAsync(forward, CancellationToken.None);
Assert.IsTrue(rForward.Success);
Assert.AreEqual(CommandKind.FileDocument, rForward.Result.Kind);
Assert.AreEqual(Path.GetFullPath(file), rForward.Result.Target, StringComparer.OrdinalIgnoreCase);
}
finally
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, true);
}
}
}
}

View File

@@ -2,9 +2,14 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Bookmarks.Commands;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
@@ -129,4 +134,73 @@ public class BookmarksCommandProviderTests
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
Assert.IsNotNull(addCommand);
}
[TestMethod]
[Timeout(5000)]
public async Task ProviderPassesResolverClassificationToLauncher_Unchanged()
{
// Arrange
var bookmarkAddress = @"C:\Données\été\résumé.txt";
var bookmark = new BookmarkData("Accented bookmark", bookmarkAddress);
var mockBookmarkManager = new MockBookmarkManager(bookmark);
var expectedClassification = new Classification(
CommandKind.FileDocument,
bookmarkAddress,
bookmarkAddress,
string.Empty,
LaunchMethod.ShellExecute,
@"C:\Données\été",
false);
var resolver = new TestBookmarkResolver(expectedClassification);
var mockLauncher = new MockProcessLauncher();
var provider = new BookmarksCommandProvider(mockBookmarkManager, resolver, processLauncher: mockLauncher);
var commands = provider.TopLevelCommands();
var bookmarkItem = commands.OfType<Pages.BookmarkListItem>().Single();
await bookmarkItem.IsInitialized;
var launchCommand = bookmarkItem.Command as LaunchBookmarkCommand;
Assert.IsNotNull(launchCommand);
// Act — invoke the command; the mock launcher captures what's launched
_ = launchCommand.Invoke(this);
// Assert
Assert.AreEqual(bookmarkAddress, resolver.LastClassifyInput);
Assert.IsNotNull(mockLauncher.LastLaunchedClassification);
Assert.AreEqual(expectedClassification.Target, mockLauncher.LastLaunchedClassification.Target);
Assert.AreEqual(expectedClassification.Kind, mockLauncher.LastLaunchedClassification.Kind);
Assert.AreEqual(expectedClassification.Launch, mockLauncher.LastLaunchedClassification.Launch);
}
private sealed class MockProcessLauncher : IProcessLauncher
{
public Classification? LastLaunchedClassification { get; private set; }
public bool Launch(Classification classification, bool runAsAdmin = false)
{
LastLaunchedClassification = classification;
return true;
}
}
private sealed class TestBookmarkResolver : IBookmarkResolver
{
private readonly Classification _classification;
internal string? LastClassifyInput { get; private set; }
internal TestBookmarkResolver(Classification classification)
{
_classification = classification;
}
public Task<(bool Success, Classification Result)> TryClassifyAsync(string input, System.Threading.CancellationToken cancellationToken = default)
=> Task.FromResult((true, _classification));
public Classification ClassifyOrUnknown(string input)
{
LastClassifyInput = input;
return _classification;
}
}
}

View File

@@ -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.
#nullable enable
using System;
using System.Text;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
/// <summary>
/// Tests for <see cref="PathNormalization.NormalizePathForWindows(string)"/>.
/// <para>
/// Long-path / NT-object prefix stripping is Windows-only behavior, so the
/// prefix-handling tests assert Windows expectations and use
/// <see cref="Assert.Inconclusive(string)"/> on non-Windows agents so CI on
/// Linux/macOS skips them cleanly without reporting failures.
/// </para>
/// <para>
/// Cross-platform behavior (NFC, trailing whitespace) is asserted on every OS.
/// </para>
/// </summary>
[TestClass]
public class PathNormalizationTests
{
private static void RequireWindows()
{
if (!OperatingSystem.IsWindows())
{
Assert.Inconclusive("Windows-only behavior; skipped on non-Windows CI agents.");
}
}
[TestMethod]
public void Null_Or_Empty_Is_Returned_Unchanged()
{
Assert.AreEqual(string.Empty, PathNormalization.NormalizePathForWindows(string.Empty));
Assert.AreEqual(null, PathNormalization.NormalizePathForWindows(null!));
}
[TestMethod]
public void Ascii_Path_Is_Unchanged()
{
const string path = @"C:\Users\Public\Documents\readme.txt";
Assert.AreEqual(path, PathNormalization.NormalizePathForWindows(path));
}
[TestMethod]
public void Decomposed_Accents_Are_Normalized_To_NFC()
{
// "ÉCOLES" composed two ways: precomposed (NFC) vs decomposed (NFD).
var nfc = "\u00C9COLES"; // É (U+00C9) + COLES
var nfd = "E\u0301COLES"; // E + COMBINING ACUTE ACCENT (U+0301) + COLES
Assert.AreNotEqual(nfc, nfd, "Sanity: the two encodings differ before normalization.");
var normalized = PathNormalization.NormalizePathForWindows(nfd);
Assert.AreEqual(nfc, normalized);
Assert.IsTrue(normalized.IsNormalized(NormalizationForm.FormC));
}
[TestMethod]
public void NonAscii_Characters_Are_Preserved_Without_Loss()
{
// Mix of accented Latin, CJK, emoji (surrogate pair), and a Cyrillic letter.
const string original = "C:\\données\\café\\日本語\\🚀\\Привет.txt";
var normalized = PathNormalization.NormalizePathForWindows(original);
Assert.AreEqual(original.Normalize(NormalizationForm.FormC), normalized);
StringAssert.Contains(normalized, "données");
StringAssert.Contains(normalized, "café");
StringAssert.Contains(normalized, "日本語");
StringAssert.Contains(normalized, "🚀");
StringAssert.Contains(normalized, "Привет");
}
[TestMethod]
public void Trailing_Ascii_Whitespace_Is_Trimmed()
{
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows("C:\\foo\\bar.txt "));
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows("C:\\foo\\bar.txt\t"));
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows("C:\\foo\\bar.txt\r\n"));
}
[TestMethod]
public void Trailing_NonBreaking_Space_Is_Trimmed()
{
// U+00A0 NO-BREAK SPACE — frequently introduced by copy/paste from web pages.
var input = "C:\\foo\\bar.txt\u00A0";
Assert.AreEqual(@"C:\foo\bar.txt", PathNormalization.NormalizePathForWindows(input));
}
[TestMethod]
public void Internal_Whitespace_Is_Preserved()
{
const string input = @"C:\Program Files\My App\file name.txt";
Assert.AreEqual(input, PathNormalization.NormalizePathForWindows(input));
}
[TestMethod]
public void Trailing_Dots_Are_Preserved()
{
// Trimming trailing dots could be lossy (e.g., "foo..." vs "foo").
// We deliberately keep them; Windows callers can decide what to do.
const string input = @"C:\foo\bar...";
Assert.AreEqual(input, PathNormalization.NormalizePathForWindows(input));
}
[TestMethod]
public void LongPath_Prefix_Is_Stripped_On_Windows()
{
RequireWindows();
Assert.AreEqual(
@"C:\very\long\path\file.txt",
PathNormalization.NormalizePathForWindows(@"\\?\C:\very\long\path\file.txt"));
}
[TestMethod]
public void LongUnc_Prefix_Is_Rewritten_To_Unc_On_Windows()
{
RequireWindows();
Assert.AreEqual(
@"\\server\share\file.txt",
PathNormalization.NormalizePathForWindows(@"\\?\UNC\server\share\file.txt"));
}
[TestMethod]
public void NtObject_Prefix_Is_Stripped_On_Windows()
{
RequireWindows();
Assert.AreEqual(
@"C:\Windows\System32\notepad.exe",
PathNormalization.NormalizePathForWindows(@"\??\C:\Windows\System32\notepad.exe"));
}
[TestMethod]
public void Unc_Without_LongPath_Prefix_Is_Untouched_On_Windows()
{
RequireWindows();
const string input = @"\\server\share\file.txt";
Assert.AreEqual(input, PathNormalization.NormalizePathForWindows(input));
}
}

View File

@@ -20,10 +20,11 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
private const int LoadStateLoading = 1;
private const int LoadStateLoaded = 2;
private readonly IPlaceholderParser _placeholderParser = new PlaceholderParser();
private readonly IPlaceholderParser _placeholderParser;
private readonly IBookmarksManager _bookmarksManager;
private readonly IBookmarkResolver _commandResolver;
private readonly IBookmarkIconLocator _iconLocator = new IconLocator();
private readonly IProcessLauncher _processLauncher;
public IBookmarksManager BookmarksManager => _bookmarksManager;
@@ -46,14 +47,16 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
return new BookmarksCommandProvider(new BookmarksManager(new FileBookmarkDataSource(StateJsonPath())));
}
internal BookmarksCommandProvider(IBookmarksManager bookmarksManager)
internal BookmarksCommandProvider(IBookmarksManager bookmarksManager, IBookmarkResolver? commandResolver = null, IPlaceholderParser? placeholderParser = null, IProcessLauncher? processLauncher = null)
{
ArgumentNullException.ThrowIfNull(bookmarksManager);
_placeholderParser = placeholderParser ?? new PlaceholderParser();
_bookmarksManager = bookmarksManager;
_bookmarksManager.BookmarkAdded += OnBookmarkAdded;
_bookmarksManager.BookmarkRemoved += OnBookmarkRemoved;
_commandResolver = new BookmarkResolver(_placeholderParser);
_commandResolver = commandResolver ?? new BookmarkResolver(_placeholderParser);
_processLauncher = processLauncher ?? new ProductionProcessLauncher();
Id = "Bookmarks";
DisplayName = Resources.bookmarks_display_name;
@@ -66,7 +69,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
private void OnBookmarkAdded(BookmarkData bookmarkData)
{
var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser);
var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser, _processLauncher);
lock (_bookmarksLock)
{
_bookmarks.Add(newItem);
@@ -95,7 +98,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
{
lock (_bookmarksLock)
{
_bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))];
_bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser, _processLauncher))];
_commands = BuildTopLevelCommandsUnsafe();
}
@@ -151,6 +154,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
_commandResolver,
_iconLocator,
_placeholderParser,
_processLauncher,
asBand: true))];
}
@@ -200,6 +204,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
_commandResolver,
_iconLocator,
_placeholderParser,
_processLauncher,
asBand: true);
return new WrappedDockItem(items: [listItem], id: id, displayTitle: listItem.BookmarkTitle);

View File

@@ -3,7 +3,9 @@
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.IO;
using System.Text;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
@@ -21,6 +23,7 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
private readonly IBookmarkResolver _bookmarkResolver;
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
private readonly Classification _classification;
private readonly IProcessLauncher _processLauncher;
private IIconInfo? _icon;
@@ -30,7 +33,7 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
public string Id { get; }
public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, Dictionary<string, string>? placeholders = null)
public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, IProcessLauncher? processLauncher = null, Dictionary<string, string>? placeholders = null)
{
ArgumentNullException.ThrowIfNull(bookmarkData);
ArgumentNullException.ThrowIfNull(classification);
@@ -39,6 +42,7 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
_classification = classification;
_placeholders = placeholders;
_bookmarkResolver = bookmarkResolver;
_processLauncher = processLauncher ?? new ProductionProcessLauncher();
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
Name = Resources.bookmarks_command_name_open;
@@ -64,9 +68,16 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
public ICommandResult Invoke(object sender)
{
var bookmarkAddress = ReplacePlaceholders(_bookmarkData.Bookmark);
var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress);
var success = CommandLauncher.Launch(classification);
var success = false;
try
{
var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress);
success = LaunchWithFilesystemFallback(classification);
}
catch (Exception ex)
{
Logger.LogError($"Failed to launch bookmark '{bookmarkAddress}'", ex);
}
return success
? CommandResult.Dismiss()
@@ -79,6 +90,66 @@ internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokable
});
}
private bool LaunchWithFilesystemFallback(Classification classification)
{
if (TryLaunch(classification))
{
return true;
}
if (!IsFilesystemFallbackCandidate(classification))
{
return false;
}
if (!TryGetNearestExistingParentDirectory(classification, out var parentDirectory))
{
return false;
}
var fallbackClassification = classification with
{
Kind = CommandKind.Directory,
Target = parentDirectory,
Arguments = string.Empty,
Launch = LaunchMethod.ExplorerOpen,
WorkingDirectory = parentDirectory,
FileSystemTarget = parentDirectory,
};
return TryLaunch(fallbackClassification);
}
private bool TryLaunch(Classification classification)
{
try
{
return _processLauncher.Launch(classification);
}
catch (Exception ex)
{
Logger.LogError($"Failed to launch bookmark target '{classification.Target}'", ex);
return false;
}
}
private static bool IsFilesystemFallbackCandidate(Classification classification) =>
classification.Kind switch
{
CommandKind.Directory => true,
CommandKind.FileDocument => true,
CommandKind.Shortcut => true,
CommandKind.InternetShortcut => true,
CommandKind.VirtualShellItem => !string.IsNullOrWhiteSpace(classification.FileSystemTarget),
_ => false,
};
private static bool TryGetNearestExistingParentDirectory(Classification classification, out string parentDirectory)
{
// Delegate to shared helper which applies normalization and consistent probing
return PathHelpers.TryGetNearestExistingParentDirectory(classification, out parentDirectory);
}
private string ReplacePlaceholders(string input)
{
var result = input;

View File

@@ -17,8 +17,8 @@ internal static class CommandLauncher
/// <param name="runAsAdmin">Optional: force elevation if possible.</param>
public static bool Launch(Classification classification, bool runAsAdmin = false)
{
switch (classification.Launch)
{
switch (classification.Launch)
{
case LaunchMethod.ExplorerOpen:
// Folders and shell: URIs are best handled by explorer.exe
// You can notice the difference with Recycle Bin for example:

View File

@@ -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.Ext.Bookmarks.Helpers;
internal interface IProcessLauncher
{
bool Launch(Classification classification, bool runAsAdmin = false);
}

View File

@@ -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.
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
internal sealed class ProductionProcessLauncher : IProcessLauncher
{
public bool Launch(Classification classification, bool runAsAdmin = false)
{
return CommandLauncher.Launch(classification, runAsAdmin);
}
}

View File

@@ -24,6 +24,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
private readonly IBookmarkResolver _commandResolver;
private readonly IBookmarkIconLocator _iconLocator;
private readonly IPlaceholderParser _placeholderParser;
private readonly IProcessLauncher _processLauncher;
private readonly SupersedingAsyncValueGate<BookmarkListItemReclassifyResult> _classificationGate;
private readonly TaskCompletionSource _initializationTcs = new();
private readonly bool _isBandItem;
@@ -44,11 +45,13 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
IBookmarkResolver commandResolver,
IBookmarkIconLocator iconLocator,
IPlaceholderParser placeholderParser,
IProcessLauncher processLauncher,
bool asBand = false)
{
ArgumentNullException.ThrowIfNull(bookmark);
ArgumentNullException.ThrowIfNull(bookmarksManager);
ArgumentNullException.ThrowIfNull(commandResolver);
ArgumentNullException.ThrowIfNull(processLauncher);
_isBandItem = asBand;
_bookmark = bookmark;
_bookmarksManager = bookmarksManager;
@@ -56,6 +59,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
_commandResolver = commandResolver;
_iconLocator = iconLocator;
_placeholderParser = placeholderParser;
_processLauncher = processLauncher;
_classificationGate = new SupersedingAsyncValueGate<BookmarkListItemReclassifyResult>(ClassifyAsync, ApplyClassificationResult);
_ = _classificationGate.ExecuteAsync();
}
@@ -109,7 +113,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
ICommand command = classification.IsPlaceholder
? new BookmarkPlaceholderPage(_bookmark, _iconLocator, _commandResolver, _placeholderParser)
: new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver);
: new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver, _processLauncher);
BuildSpecificContextMenuItems(classification, contextMenu);
AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu);

View File

@@ -215,14 +215,19 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
var (firstHead, tail) = SplitHeadAndArgs(input);
CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head);
// Normalize a copy for path-detection/probing only. We must not overwrite 'head'
// itself, because normalization trims trailing whitespace and would turn a meaningful
// token (e.g. a quoted single space) into an empty string for the fallback target below.
var normalizedHead = NormalizePathForWindows(head);
// 3.1) UWP/AppX via AppsFolder/AUMID or pkgfamily!app
// Since the AUMID can be actually anything, we either take a full shell:AppsFolder\AUMID
// as entered and we try to detect packaged app ids (pkgfamily!app).
if (TryGetAumid(head, out var aumid2))
if (TryGetAumid(normalizedHead, out var aumid2))
{
result = new Classification(
CommandKind.Aumid,
head,
normalizedHead,
aumid2,
tail,
LaunchMethod.ActivateAppId,
@@ -234,7 +239,7 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
// 3.2) It's a virtual shell item (e.g. Control Panel, Recycle Bin, This PC)
// Shell items that are backed by filesystem paths (e.g. Downloads) should be already handled above.
if (CommandLineHelper.HasShellPrefix(head))
if (CommandLineHelper.HasShellPrefix(normalizedHead))
{
ShellNames.TryGetFriendlyName(input, out var displayName);
ShellNames.TryGetFileSystemPath(input, out var fsPath);
@@ -254,7 +259,7 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
// 3.3) Search paths for the file name (with or without ext)
// If head is a file name with extension, we look only for that. If there's no extension
// we go and follow Windows Shell resolution rules.
if (TryResolveViaPath(head, out var resolvedFilePath))
if (TryResolveViaPath(normalizedHead, out var resolvedFilePath))
{
result = new Classification(
CommandKind.PathCommand,
@@ -269,9 +274,9 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
}
// 3.4) If it looks like a path with ext but missing file, treat as document (Shell will handle assoc / error)
if (LooksPathy(head) && Path.HasExtension(head))
if (LooksPathy(normalizedHead) && Path.HasExtension(normalizedHead))
{
var extension = Path.GetExtension(head);
var extension = Path.GetExtension(normalizedHead);
// if the path extension contains placeholders, we can't assume what it is so, skip it and treat it as unknown
var hasSpecificExtension = !isPlaceholder || !extension.Contains('{');
@@ -280,10 +285,10 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
result = new Classification(
ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument,
input,
head,
normalizedHead,
tail,
LaunchMethod.ShellExecute,
HasDir(head) ? Path.GetDirectoryName(head) : null,
HasDir(normalizedHead) ? Path.GetDirectoryName(normalizedHead) : null,
isPlaceholder);
return true;
@@ -291,7 +296,7 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
}
// 4) looks like a web URL without scheme, but not like a file with extension
if (head.Contains('.', StringComparison.OrdinalIgnoreCase) && head.StartsWith("www", StringComparison.OrdinalIgnoreCase))
if (normalizedHead.Contains('.', StringComparison.OrdinalIgnoreCase) && normalizedHead.StartsWith("www", StringComparison.OrdinalIgnoreCase))
{
// treat as URL, add https://
var url = "https://" + input;
@@ -329,8 +334,6 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
{
try
{
// This goes greedy from the longest head down to shortest; exactly opposite of what
// CreateProcess rules are for the first token. But here we operate with a slightly different goal.
var (greedyHead, greedyTail) = GreedyFind(head, containsPlaceholders, placeholderParser);
// put tails back together:
@@ -345,7 +348,11 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser)
{
// Be greedy: try to find the longest existing path prefix
// Be greedy: try to find the longest existing path prefix.
// Only probe at whitespace boundaries to find where the command ends and arguments begin.
// We intentionally do NOT split at path separators — that would act as parent-directory
// fallback, misclassifying non-existing files (e.g. classifying C:\Users\x\file.txt as
// Directory because C:\Users exists).
for (var i = input.Length; i >= 0; i--)
{
if (i < input.Length && !char.IsWhiteSpace(input[i]))
@@ -353,29 +360,24 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
continue;
}
var candidate = input.AsSpan(0, i).TrimEnd().ToString();
var candidate = input.AsSpan(0, i).TrimEnd();
if (candidate.Length == 0)
{
continue;
}
var candidateStr = candidate.ToString();
// If we have placeholders, check if this candidate would contain a non-path placeholder
if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser))
if (containsPlaceholders && ContainsNonPathPlaceholder(candidateStr, placeholderParser))
{
continue; // Skip this candidate, try a shorter one
}
try
if (TryResolvePathCandidate(candidateStr, out var resolvedCandidate))
{
if (CommandLineHelper.ExpandPathToPhysicalFile(candidate, true, out var full))
{
var tail = i < input.Length ? input[i..].TrimStart() : string.Empty;
return (full, tail);
}
}
catch
{
// Ignore malformed paths; keep scanning
var tail = i < input.Length ? input[i..].TrimStart() : string.Empty;
return (resolvedCandidate, tail);
}
}
@@ -525,11 +527,109 @@ internal sealed partial class BookmarkResolver : IBookmarkResolver
return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile);
}
private static bool TryResolvePathCandidate(string candidate, out string resolvedPath)
{
resolvedPath = string.Empty;
var normalizedCandidate = NormalizePathForWindows(candidate);
if (TryResolvePathCandidateCore(normalizedCandidate, out resolvedPath))
{
return true;
}
if (TryPercentDecodePathCandidate(normalizedCandidate, out var decodedCandidate)
&& !decodedCandidate.Equals(normalizedCandidate, StringComparison.Ordinal)
&& TryResolvePathCandidateCore(decodedCandidate, out resolvedPath))
{
return true;
}
return false;
}
private static bool TryResolvePathCandidateCore(string candidate, out string resolvedPath)
{
resolvedPath = string.Empty;
try
{
var normalizedCandidate = NormalizePathForWindows(candidate);
if (CommandLineHelper.ExpandPathToPhysicalFile(normalizedCandidate, true, out var expanded))
{
var normalizedExpanded = NormalizePathForWindows(expanded);
if (File.Exists(normalizedExpanded) || Directory.Exists(normalizedExpanded))
{
resolvedPath = normalizedExpanded;
return true;
}
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to resolve path candidate '{candidate}'", ex);
}
return false;
}
private static bool TryPercentDecodePathCandidate(string input, out string decoded)
{
decoded = input;
if (!ContainsPercentEncodingSequence(input))
{
return false;
}
try
{
decoded = NormalizePathForWindows(Uri.UnescapeDataString(input));
return true;
}
catch (Exception ex)
{
Logger.LogError($"Failed to percent-decode path candidate '{input}'", ex);
return false;
}
}
private static bool ContainsPercentEncodingSequence(string input)
{
for (var i = 0; i + 2 < input.Length; i++)
{
if (input[i] == '%'
&& IsHexDigit(input[i + 1])
&& IsHexDigit(input[i + 2]))
{
return true;
}
}
return false;
}
private static bool IsHexDigit(char c) =>
(c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
private static bool TryGetNearestExistingParentDirectory(string path, out string parentDirectory)
{
// Delegate to shared helper to avoid duplicated logic across the extension
return PathHelpers.TryGetNearestExistingParentDirectory(path, out parentDirectory);
}
private static string NormalizePathForWindows(string path) =>
PathNormalization.NormalizePathForWindows(path);
private static string? TryProbe(string? dir, string name)
{
try
{
var path = dir is null ? name : Path.Combine(dir, name);
// Normalize before probing filesystem
path = NormalizePathForWindows(path);
if (File.Exists(path))
{
return Path.GetFullPath(path);

View File

@@ -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.
using System;
using System.IO;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
internal static class PathHelpers
{
/// <summary>
/// Finds the nearest existing parent directory for a given path string.
/// Normalizes the input (NFC) before probing and trims long-path prefixes on Windows.
/// </summary>
public static bool TryGetNearestExistingParentDirectory(string path, out string parentDirectory)
{
parentDirectory = string.Empty;
try
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var current = PathNormalization.NormalizePathForWindows(path)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
while (!string.IsNullOrWhiteSpace(current))
{
if (Directory.Exists(current))
{
parentDirectory = PathNormalization.NormalizePathForWindows(Path.GetFullPath(current));
return true;
}
var next = Path.GetDirectoryName(current);
if (string.IsNullOrEmpty(next) || next.Equals(current, StringComparison.Ordinal))
{
break;
}
current = next;
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to resolve nearest parent directory for '{path}'", ex);
}
return false;
}
/// <summary>
/// Overload that accepts a Classification and probes classification.FileSystemTarget if present,
/// otherwise classification.Target.
/// </summary>
public static bool TryGetNearestExistingParentDirectory(Classification classification, out string parentDirectory)
{
parentDirectory = string.Empty;
try
{
var pathToProbe = string.IsNullOrWhiteSpace(classification.FileSystemTarget) ? classification.Target : classification.FileSystemTarget;
if (string.IsNullOrWhiteSpace(pathToProbe))
{
return false;
}
return TryGetNearestExistingParentDirectory(pathToProbe, out parentDirectory);
}
catch (Exception ex)
{
Logger.LogError($"Failed to resolve fallback folder for '{classification.Target}'", ex);
return false;
}
}
}

View File

@@ -0,0 +1,102 @@
// 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;
using System.Text;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
/// <summary>
/// Conservative, lossless normalization helpers for filesystem paths used by the
/// Bookmark resolver when probing on Windows.
/// </summary>
/// <remarks>
/// <para>
/// On Windows, the goal is to give file/directory probes a fair chance at hitting
/// an existing target without ever mangling the user's data. We:
/// </para>
/// <list type="bullet">
/// <item><description>Apply Unicode Normalization Form C (NFC) so visually equivalent
/// accented sequences (e.g. precomposed "É" vs. "E + COMBINING ACUTE ACCENT")
/// compare equal to the way Windows tools typically write them to NTFS.</description></item>
/// <item><description>Strip well-known long-path / NT-object prefixes (<c>\\?\</c>,
/// <c>\\?\UNC\</c>, <c>\??\</c>) before probing because <see cref="System.IO.File.Exists(string)"/>
/// and friends accept the un-prefixed form and many user-entered paths do not include the prefix.</description></item>
/// <item><description>Trim trailing ASCII whitespace from the whole path because Win32
/// ignores trailing spaces in filenames anyway and copy/paste from the web frequently
/// leaves a stray space or NBSP behind. Trailing dots are deliberately preserved
/// because removing them can be lossy.</description></item>
/// </list>
/// <para>
/// All transformations are length-preserving for the meaningful portion of the
/// string and never drop or substitute non-ASCII characters. On non-Windows
/// platforms only NFC normalization is applied.
/// </para>
/// </remarks>
internal static class PathNormalization
{
private const string LongPathPrefix = @"\\?\";
private const string LongUncPrefix = @"\\?\UNC\";
private const string NtObjectPrefix = @"\??\";
/// <summary>
/// Returns a normalized form of <paramref name="path"/> that is safe to feed into
/// .NET filesystem probes (<see cref="System.IO.File.Exists(string)"/>,
/// <see cref="System.IO.Directory.Exists(string)"/>, <see cref="System.IO.Path.GetFullPath(string)"/>).
/// The function is conservative: it never drops or substitutes non-ASCII characters
/// and returns the input unchanged when no normalization is needed.
/// </summary>
public static string NormalizePathForWindows(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
// NFC is safe everywhere. It compares equal byte-for-byte to most
// Windows-authored filenames and never loses characters.
var normalized = path.Normalize(NormalizationForm.FormC);
// Trim only trailing whitespace. Windows ignores trailing spaces in
// file/dir names, and copy/paste often leaves a stray space (or NBSP)
// behind. We do NOT trim trailing dots — that would be lossy.
normalized = TrimTrailingWhitespace(normalized);
if (!OperatingSystem.IsWindows())
{
return normalized;
}
// Strip long-path / NT object prefixes. .NET's filesystem APIs accept
// both prefixed and un-prefixed forms; strings stored in a bookmark
// typically lack the prefix so we normalize toward the bare form.
if (normalized.StartsWith(LongUncPrefix, StringComparison.OrdinalIgnoreCase))
{
return @"\\" + normalized[LongUncPrefix.Length..];
}
if (normalized.StartsWith(LongPathPrefix, StringComparison.OrdinalIgnoreCase))
{
return normalized[LongPathPrefix.Length..];
}
if (normalized.StartsWith(NtObjectPrefix, StringComparison.OrdinalIgnoreCase))
{
return normalized[NtObjectPrefix.Length..];
}
return normalized;
}
private static string TrimTrailingWhitespace(string value)
{
var end = value.Length;
while (end > 0 && char.IsWhiteSpace(value[end - 1]))
{
end--;
}
return end == value.Length ? value : value[..end];
}
}