From 1808d02fdcb8df24ea4e403947838eea7d94e1f1 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Fri, 23 Oct 2026 15:17:36 +0800
Subject: [PATCH 1/6] Resolve Windows shortcuts (.lnk) to their target file
when opening
Opening or dropping a shortcut now opens the log file it points at instead
of the shortcut itself. Resolution uses the shell's own IShellLink; UNC and
device targets as well as oversized files are not followed and open as
regular files.
---
.../Services/FileOperationServiceTests.cs | 98 +++++++++++
.../Services/ShortcutResolverTests.cs | 96 +++++++++++
.../FileOperationService.cs | 30 +++-
.../FileOperationService/ShortcutResolver.cs | 161 ++++++++++++++++++
4 files changed, 379 insertions(+), 6 deletions(-)
create mode 100644 src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
create mode 100644 src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 2625fb94..329bba63 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -657,6 +657,71 @@ public void LoadFilesWithOption_SingleFile_CallsAddFileTab ()
Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("test.log"));
}
+ [Test]
+ public void LoadFilesWithOption_ShortcutToLxjFile_CallsProjectCallback ()
+ {
+ // The .lxj check has to run on the resolved name, or the shortcut gets opened as a raw log.
+ var (directory, shortcut, session) = CreateShortcutToSession();
+
+ try
+ {
+ var decision = _sut.LoadFilesWithOption(new[] { shortcut }, false);
+
+ Assert.That(decision, Is.EqualTo(MultiFileDecision.Cancel));
+ Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
+ Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
+ Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void AddFileTabs_ShortcutToLxjFile_CallsProjectCallback ()
+ {
+ var (directory, shortcut, session) = CreateShortcutToSession();
+
+ try
+ {
+ _sut.AddFileTabs(new[] { shortcut });
+
+ Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
+ Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
+ Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void AddFileTab_ShortcutToLogFile_OpensTarget ()
+ {
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShortcutLog", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+
+ try
+ {
+ var log = Path.Join(directory, "actual.log");
+ File.WriteAllText(log, "line1");
+
+ var shortcut = Path.Join(directory, "actual.lnk");
+ CreateShortcut(shortcut, log);
+
+ _sut.AddFileTab(new FileTabRequest { FileName = shortcut });
+
+ Assert.That(_factoryCalls, Has.Count.EqualTo(1));
+ Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo(log), "the target file is opened, not the shortcut");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
[Test]
public void LoadFilesWithOption_SingleLxjFile_CallsProjectCallback ()
{
@@ -1225,6 +1290,39 @@ public void LoadFiles_DelegatesToAddFileTabs ()
Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("b.log"));
}
+ ///
+ /// Creates a temporary directory holding a session file and a shortcut to it, and returns the
+ /// three paths. The caller owns the directory and deletes it.
+ ///
+ private static (string Directory, string Shortcut, string Session) CreateShortcutToSession ()
+ {
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShortcutSession", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+
+ var session = Path.Join(directory, "project.lxj");
+ File.WriteAllText(session, "");
+
+ var shortcut = Path.Join(directory, "session.lnk");
+ CreateShortcut(shortcut, session);
+
+ return (directory, shortcut, session);
+ }
+
+ ///
+ /// Creates a real shortcut through the shell's WScript.Shell automation object, so the tests
+ /// exercise the same files Explorer writes.
+ ///
+ private static void CreateShortcut (string shortcutPath, string targetPath)
+ {
+ var shellType = Type.GetTypeFromProgID("WScript.Shell");
+ Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");
+
+ dynamic shell = Activator.CreateInstance(shellType)!;
+ dynamic shortcut = shell.CreateShortcut(shortcutPath);
+ shortcut.TargetPath = targetPath;
+ shortcut.Save();
+ }
+
///
/// Builds a second service on the shared config manager, standing in for another LogTabWindow
/// of the same session with its own tab controller.
diff --git a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
new file mode 100644
index 00000000..7e2071e9
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
@@ -0,0 +1,96 @@
+using System.Runtime.Versioning;
+
+using LogExpert.UI.Services.FileOperationService;
+
+using NUnit.Framework;
+
+namespace LogExpert.UI.Tests.Services;
+
+///
+/// Tests the guard rails of : oversized files and targets off the
+/// local machine are not followed, the shortcut keeps opening as a regular file instead.
+///
+[TestFixture]
+[SupportedOSPlatform("windows")]
+internal class ShortcutResolverTests
+{
+ [Test]
+ public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
+ {
+ var directory = CreateTempDirectory("ShortcutLocal");
+
+ try
+ {
+ var log = Path.Join(directory, "actual.log");
+ File.WriteAllText(log, "line1");
+
+ var shortcut = Path.Join(directory, "actual.lnk");
+ CreateShortcut(shortcut, log);
+
+ Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(log));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
+ {
+ var directory = CreateTempDirectory("ShortcutUnc");
+
+ try
+ {
+ var shortcut = Path.Join(directory, "remote.lnk");
+ CreateShortcut(shortcut, @"\\nonexistent-host\share\actual.log");
+
+ Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut),
+ "a UNC target must not be probed: File.Exists would authenticate against the remote host");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
+ {
+ var directory = CreateTempDirectory("ShortcutOversized");
+
+ try
+ {
+ var shortcut = Path.Join(directory, "oversized.lnk");
+ File.WriteAllBytes(shortcut, new byte[1024 * 1024 + 1]);
+
+ Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ private static string CreateTempDirectory (string name)
+ {
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+ return directory;
+ }
+
+ ///
+ /// Creates a real shortcut through the shell's WScript.Shell automation object, so the tests
+ /// exercise the same files Explorer writes.
+ ///
+ private static void CreateShortcut (string shortcutPath, string targetPath)
+ {
+ var shellType = Type.GetTypeFromProgID("WScript.Shell");
+ Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");
+
+ dynamic shell = Activator.CreateInstance(shellType)!;
+ dynamic shortcut = shell.CreateShortcut(shortcutPath);
+ shortcut.TargetPath = targetPath;
+ shortcut.Save();
+ }
+}
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 0c72551c..93cc3558 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -68,6 +68,15 @@ internal sealed class FileOperationService (
public LogWindow AddFileTab (FileTabRequest request)
{
+ // A shortcut resolves to its target here, so everything downstream — settings file
+ // indirection, .lxp handling, title, history — works on the target file. The request is
+ // only replaced when the name actually changed, keeping the caller's instance otherwise.
+ var resolvedFile = ShortcutResolver.Resolve(request.FileName);
+ if (!string.Equals(resolvedFile, request.FileName, StringComparison.Ordinal))
+ {
+ request = request with { FileName = resolvedFile };
+ }
+
var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry);
var normalizedPath = Path.GetFullPath(logFileName);
@@ -204,6 +213,9 @@ public LogWindow AddTempFileTab (string fileName, string title)
return null;
}
+ // Resolve shortcuts first: a .lnk to a session file must not end up as a raw log line.
+ fileNames = fileNames.Select(ShortcutResolver.Resolve).ToArray();
+
// Session files (.lxj) aren't logs — dispatch them to the project loader and
// continue with only the log files. Without this, the .lxj would be treated as
// a raw log line in the multi-file tab.
@@ -270,13 +282,16 @@ public MultiFileDecision LoadFilesWithOption (string[] fileNames, bool invertLog
if (fileNames.Length == 1)
{
- if (fileNames[0].EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
+ // Resolve first: a shortcut to a session file must not be opened as a raw log.
+ var fileName = ShortcutResolver.Resolve(fileNames[0]);
+
+ if (fileName.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
{
- _loadSessionCallback(fileNames[0], true);
+ _loadSessionCallback(fileName, true);
return MultiFileDecision.Cancel; // Already handled
}
- _ = AddFileTab(new FileTabRequest { FileName = fileNames[0] });
+ _ = AddFileTab(new FileTabRequest { FileName = fileName });
return MultiFileDecision.SingleFiles;
}
@@ -310,13 +325,16 @@ public void AddFileTabs (string[] fileNames, int? targetLine = null)
{
try
{
- if (fileName.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
+ // Resolve first: a shortcut to a session file must not be opened as a raw log.
+ var resolved = ShortcutResolver.Resolve(fileName);
+
+ if (resolved.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
{
- _loadSessionCallback(fileName, false);
+ _loadSessionCallback(resolved, false);
}
else
{
- _ = AddFileTab(new FileTabRequest { FileName = fileName, TargetLine = targetLine });
+ _ = AddFileTab(new FileTabRequest { FileName = resolved, TargetLine = targetLine });
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or LogFileException)
diff --git a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
new file mode 100644
index 00000000..a5946a6c
--- /dev/null
+++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
@@ -0,0 +1,161 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+using NLog;
+
+namespace LogExpert.UI.Services.FileOperationService;
+
+///
+/// Resolves a Windows shortcut (.lnk) to the local file it points at.
+///
+///
+///
+/// Resolution goes through the shell's own IShellLink implementation instead of parsing the
+/// MS-SHLLINK format by hand. This class lives in the UI assembly, which is
+/// net10.0-windows, so the COM dependency is safe here.
+///
+///
+/// Anything that is not a .lnk, does not exist, is implausibly large, cannot be resolved, or
+/// points off the local machine is returned unchanged: such a shortcut opens as a regular file,
+/// exactly as before.
+///
+///
+[SupportedOSPlatform("windows")]
+internal static class ShortcutResolver
+{
+ #region Constants
+
+ private const string SHORTCUT_EXTENSION = ".lnk";
+
+ /// Buffer size for IShellLinkW::GetPath, following the shell's own MAX_PATH.
+ private const int TARGET_PATH_CAPACITY = 260;
+
+ ///
+ /// Real shortcuts are a few KB. Anything larger is not one, and handing it to the shell parser
+ /// whole would let a dropped file spike memory.
+ ///
+ private const int MAX_SHORTCUT_SIZE = 1024 * 1024;
+
+ /// SLGP_RAWPATH: retrieve the stored target path without resolving it against the current shell state.
+ private const uint SLGP_RAWPATH = 0x00000004;
+
+ /// STGM_READ: open the shortcut file read-only.
+ private const uint STGM_READ = 0x00000000;
+
+ #endregion
+
+ #region Fields
+
+ private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
+
+ #endregion
+
+ #region Public methods
+
+ ///
+ /// Resolves to the file a shortcut points at.
+ ///
+ /// A path that may or may not be a shortcut.
+ ///
+ /// The target path when it exists; otherwise unchanged.
+ ///
+ public static string Resolve (string fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName)
+ || !Path.GetExtension(fileName).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase)
+ || !File.Exists(fileName))
+ {
+ return fileName;
+ }
+
+ try
+ {
+ if (new FileInfo(fileName).Length > MAX_SHORTCUT_SIZE)
+ {
+ _logger.Warn("Ignoring {Shortcut}: it is above the {Limit} byte shortcut limit", fileName, MAX_SHORTCUT_SIZE);
+ return fileName;
+ }
+
+ IShellLink shellLink = (IShellLink)new ShellLinkClass();
+ ((IPersistFile)shellLink).Load(fileName, STGM_READ);
+
+ var buffer = new System.Text.StringBuilder(TARGET_PATH_CAPACITY);
+ shellLink.GetPath(buffer, buffer.Capacity, IntPtr.Zero, SLGP_RAWPATH);
+
+ var target = buffer.ToString();
+ if (target.Length == 0)
+ {
+ _logger.Debug("Shortcut {Shortcut} has no target path, keeping it", fileName);
+ return fileName;
+ }
+
+ // A UNC target would make File.Exists authenticate against the remote host, handing it
+ // the user's NetNTLM hash, and device paths (\\.\, \\?\) can block. Such a shortcut
+ // opens as a regular file instead.
+ if (target.StartsWith(@"\\", StringComparison.Ordinal))
+ {
+ _logger.Debug("Shortcut target {Target} is not a local path, keeping {Shortcut}", target, fileName);
+ return fileName;
+ }
+
+ if (!File.Exists(target))
+ {
+ _logger.Debug("Shortcut target {Target} does not exist, keeping {Shortcut}", target, fileName);
+ return fileName;
+ }
+
+ return target;
+ }
+ catch (Exception ex) when (ex is COMException
+ or ExternalException
+ or IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException)
+ {
+ _logger.Debug(ex, "Could not resolve shortcut {Shortcut}, keeping it", fileName);
+ return fileName;
+ }
+ }
+
+ #endregion
+
+ #region COM declarations
+
+ /// The shell link CoClass (CLSID_ShellLink).
+ [ComImport]
+ [Guid("00021401-0000-0000-C000-000000000046")]
+ private class ShellLinkClass
+ {
+ }
+
+ ///
+ /// IShellLinkW, declared with only its first method (GetPath). The interface's
+ /// remaining methods are never called, so they are left out.
+ ///
+ [ComImport]
+ [Guid("000214F9-0000-0000-C000-000000000046")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IShellLink
+ {
+ void GetPath ([MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder pszFile, int cchMaxPath, IntPtr pFindData, uint fFlags);
+ }
+
+ /// IPersistFile, used to load the shortcut file into the shell link object.
+ [ComImport]
+ [Guid("0000010B-0000-0000-C000-000000000046")]
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IPersistFile
+ {
+ // From IPersist, which IPersistFile extends.
+ void GetClassID (out Guid pClassID);
+
+ // IsDirty precedes Load in the vtable and has to be declared to keep the slot order.
+ [PreserveSig]
+ int IsDirty ();
+
+ void Load ([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode);
+ }
+
+ #endregion
+}
From ea20c4f4a0d7ba7896af55f0a064811f93c21056 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Fri, 23 Oct 2026 18:36:09 +0800
Subject: [PATCH 2/6] Harden shortcut resolution and switch interop to Vanara
Security: normalize the raw .lnk target (env-var expansion, resolve
relative to the shortcut directory, Path.GetFullPath) before any
existence probe, and only follow plain local drive targets (X:\).
Disguised UNC/device forms (//host/share, \?\UNC\, \.\C:\) are now
rejected, closing the NetNTLM leak via File.Exists. The guard is a
pure internal SanitizeLocalTarget, unit-tested directly for the
forward-slash and \?\ / \. \ forms the shell parser cannot be fed.
Interop: replace hand-rolled [ComImport] declarations with Vanara's
CShellLinkW/IShellLinkW, matching the rest of the codebase.
Path handling: expand environment variables, resolve relative targets
against the .lnk's directory, widen the GetPath buffer to 1024.
Behaviour: sort after resolving so multi-file order follows target
names; resolution is one hop; the repeated resolve-then-.lxj pattern
in FileOperationService is consolidated into a ResolveLogFile helper.
Tests: shared TestShortcuts helper with proper COM release; oversized
test uses the internal MAX_SHORTCUT_SIZE; new guard tests lock in the
UNC/device-path rejection.
---
.../Services/FileOperationServiceTests.cs | 19 +--
.../Services/ShortcutResolverTests.cs | 70 +++++++----
.../Services/TestShortcuts.cs | 39 ++++++
.../FileOperationService.cs | 50 +++++---
.../FileOperationService/ShortcutResolver.cs | 111 +++++++++---------
5 files changed, 179 insertions(+), 110 deletions(-)
create mode 100644 src/LogExpert.UI.Tests/Services/TestShortcuts.cs
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 329bba63..46b67a32 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -709,7 +709,7 @@ public void AddFileTab_ShortcutToLogFile_OpensTarget ()
File.WriteAllText(log, "line1");
var shortcut = Path.Join(directory, "actual.lnk");
- CreateShortcut(shortcut, log);
+ TestShortcuts.Create(shortcut, log);
_sut.AddFileTab(new FileTabRequest { FileName = shortcut });
@@ -1303,26 +1303,11 @@ private static (string Directory, string Shortcut, string Session) CreateShortcu
File.WriteAllText(session, "");
var shortcut = Path.Join(directory, "session.lnk");
- CreateShortcut(shortcut, session);
+ TestShortcuts.Create(shortcut, session);
return (directory, shortcut, session);
}
- ///
- /// Creates a real shortcut through the shell's WScript.Shell automation object, so the tests
- /// exercise the same files Explorer writes.
- ///
- private static void CreateShortcut (string shortcutPath, string targetPath)
- {
- var shellType = Type.GetTypeFromProgID("WScript.Shell");
- Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");
-
- dynamic shell = Activator.CreateInstance(shellType)!;
- dynamic shortcut = shell.CreateShortcut(shortcutPath);
- shortcut.TargetPath = targetPath;
- shortcut.Save();
- }
-
///
/// Builds a second service on the shared config manager, standing in for another LogTabWindow
/// of the same session with its own tab controller.
diff --git a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
index 7e2071e9..9a2d5aa5 100644
--- a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
+++ b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
@@ -7,8 +7,12 @@
namespace LogExpert.UI.Tests.Services;
///
-/// Tests the guard rails of : oversized files and targets off the
-/// local machine are not followed, the shortcut keeps opening as a regular file instead.
+/// Tests the guard rails of : end to end via
+/// for whole-file behaviour (real shortcuts, oversized
+/// files, plain UNC targets), and directly on
+/// for disguised target forms — the shell parser cannot be fed strings like
+/// //host/share through any API a test could use, but the guard must reject them, so it is
+/// written as a pure function and tested as one.
///
[TestFixture]
[SupportedOSPlatform("windows")]
@@ -25,7 +29,7 @@ public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
File.WriteAllText(log, "line1");
var shortcut = Path.Join(directory, "actual.lnk");
- CreateShortcut(shortcut, log);
+ TestShortcuts.Create(shortcut, log);
Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(log));
}
@@ -43,7 +47,7 @@ public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
try
{
var shortcut = Path.Join(directory, "remote.lnk");
- CreateShortcut(shortcut, @"\\nonexistent-host\share\actual.log");
+ TestShortcuts.Create(shortcut, @"\\nonexistent-host\share\actual.log");
Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut),
"a UNC target must not be probed: File.Exists would authenticate against the remote host");
@@ -62,7 +66,7 @@ public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
try
{
var shortcut = Path.Join(directory, "oversized.lnk");
- File.WriteAllBytes(shortcut, new byte[1024 * 1024 + 1]);
+ File.WriteAllBytes(shortcut, new byte[ShortcutResolver.MAX_SHORTCUT_SIZE + 1]);
Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut));
}
@@ -72,25 +76,51 @@ public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
}
}
- private static string CreateTempDirectory (string name)
+ [TestCase("//nonexistent-host/share/actual.log")]
+ [TestCase(@"\\?\UNC\nonexistent-host\share\actual.log")]
+ [TestCase(@"\\?\C:\Windows\system32\drivers\etc\hosts")]
+ [TestCase(@"\\.\C:\nonexistent\actual.log")]
+ [TestCase(@"\??\UNC\nonexistent-host\share\actual.log")]
+ public void Sanitize_DisguisedNonLocalTarget_IsRejected (string target)
{
- var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
- _ = Directory.CreateDirectory(directory);
- return directory;
+ Assert.That(ShortcutResolver.SanitizeLocalTarget(target, @"C:\logs"), Is.Null,
+ $"'{target}' must not be followed: probing it could reach the network");
}
- ///
- /// Creates a real shortcut through the shell's WScript.Shell automation object, so the tests
- /// exercise the same files Explorer writes.
- ///
- private static void CreateShortcut (string shortcutPath, string targetPath)
+ [Test]
+ public void Sanitize_PlainLocalTarget_ReturnsFullPath ()
+ {
+ Assert.That(ShortcutResolver.SanitizeLocalTarget(@"C:\logs\actual.log", @"D:\other"),
+ Is.EqualTo(@"C:\logs\actual.log"));
+ }
+
+ [Test]
+ public void Sanitize_RelativeTarget_ResolvesAgainstShortcutDirectory ()
+ {
+ Assert.That(ShortcutResolver.SanitizeLocalTarget(@"sub\actual.log", @"C:\logs"),
+ Is.EqualTo(@"C:\logs\sub\actual.log"));
+ }
+
+ [Test]
+ public void Sanitize_EnvironmentVariableTarget_IsExpanded ()
{
- var shellType = Type.GetTypeFromProgID("WScript.Shell");
- Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");
+ Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", @"C:\logs", EnvironmentVariableTarget.Process);
- dynamic shell = Activator.CreateInstance(shellType)!;
- dynamic shortcut = shell.CreateShortcut(shortcutPath);
- shortcut.TargetPath = targetPath;
- shortcut.Save();
+ try
+ {
+ Assert.That(ShortcutResolver.SanitizeLocalTarget(@"%LOGEXPERT_TEST_VAR%\actual.log", @"D:\other"),
+ Is.EqualTo(@"C:\logs\actual.log"));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", null, EnvironmentVariableTarget.Process);
+ }
+ }
+
+ private static string CreateTempDirectory (string name)
+ {
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+ return directory;
}
}
diff --git a/src/LogExpert.UI.Tests/Services/TestShortcuts.cs b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
new file mode 100644
index 00000000..c29edf27
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
@@ -0,0 +1,39 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+using NUnit.Framework;
+
+namespace LogExpert.UI.Tests.Services;
+
+///
+/// Creates real shortcuts through the shell's WScript.Shell automation object, so the tests
+/// exercise the same files Explorer writes. The COM objects are released right after saving.
+///
+[SupportedOSPlatform("windows")]
+internal static class TestShortcuts
+{
+ public static void Create (string shortcutPath, string targetPath)
+ {
+ var shellType = Type.GetTypeFromProgID("WScript.Shell");
+ Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");
+
+ dynamic shell = Activator.CreateInstance(shellType)!;
+ object? shortcut = null;
+ try
+ {
+ dynamic link = shell.CreateShortcut(shortcutPath);
+ shortcut = link;
+ link.TargetPath = targetPath;
+ link.Save();
+ }
+ finally
+ {
+ if (shortcut != null)
+ {
+ Marshal.FinalReleaseComObject(shortcut);
+ }
+
+ Marshal.FinalReleaseComObject(shell);
+ }
+ }
+}
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 93cc3558..77fa16d7 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -68,9 +68,6 @@ internal sealed class FileOperationService (
public LogWindow AddFileTab (FileTabRequest request)
{
- // A shortcut resolves to its target here, so everything downstream — settings file
- // indirection, .lxp handling, title, history — works on the target file. The request is
- // only replaced when the name actually changed, keeping the caller's instance otherwise.
var resolvedFile = ShortcutResolver.Resolve(request.FileName);
if (!string.Equals(resolvedFile, request.FileName, StringComparison.Ordinal))
{
@@ -213,7 +210,6 @@ public LogWindow AddTempFileTab (string fileName, string title)
return null;
}
- // Resolve shortcuts first: a .lnk to a session file must not end up as a raw log line.
fileNames = fileNames.Select(ShortcutResolver.Resolve).ToArray();
// Session files (.lxj) aren't logs — dispatch them to the project loader and
@@ -278,20 +274,23 @@ public void LoadDroppedFiles (string[] selectedFiles, bool invertLogic, Func
+ /// Resolves a possible shortcut and dispatches a resolved session file (.lxj) to the
+ /// session loader. Returns the name to open as a log, or when the file
+ /// was a session and has been handled.
+ ///
+ private string? ResolveLogFile (string fileName, bool asFirstFile)
+ {
+ var resolved = ShortcutResolver.Resolve(fileName);
+ if (resolved.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
+ {
+ _loadSessionCallback(resolved, asFirstFile);
+ return null;
+ }
+
+ return resolved;
+ }
+
public LogWindow? PasteFromClipboard ()
{
var text = _clipboardTextProvider();
diff --git a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
index a5946a6c..872551d8 100644
--- a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
@@ -1,8 +1,14 @@
using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.ComTypes;
using System.Runtime.Versioning;
+using System.Text;
using NLog;
+using IShellLinkW = Vanara.PInvoke.Shell32.IShellLinkW;
+using CShellLinkW = Vanara.PInvoke.Shell32.CShellLinkW;
+using SLGP = Vanara.PInvoke.Shell32.SLGP;
+
namespace LogExpert.UI.Services.FileOperationService;
///
@@ -10,14 +16,15 @@ namespace LogExpert.UI.Services.FileOperationService;
///
///
///
-/// Resolution goes through the shell's own IShellLink implementation instead of parsing the
-/// MS-SHLLINK format by hand. This class lives in the UI assembly, which is
-/// net10.0-windows, so the COM dependency is safe here.
+/// Resolution goes through the shell's own IShellLink implementation (Vanara's
+/// ) instead of parsing the MS-SHLLINK format by hand. This class lives in
+/// the UI assembly, which is net10.0-windows, so the COM dependency is safe here.
///
///
/// Anything that is not a .lnk, does not exist, is implausibly large, cannot be resolved, or
/// points off the local machine is returned unchanged: such a shortcut opens as a regular file,
-/// exactly as before.
+/// exactly as before. Resolution is one hop: a target that is itself a shortcut is returned as-is
+/// and resolved by the next caller.
///
///
[SupportedOSPlatform("windows")]
@@ -27,20 +34,19 @@ internal static class ShortcutResolver
private const string SHORTCUT_EXTENSION = ".lnk";
- /// Buffer size for IShellLinkW::GetPath, following the shell's own MAX_PATH.
- private const int TARGET_PATH_CAPACITY = 260;
+ ///
+ /// Buffer size for IShellLinkW::GetPath, above MAX_PATH so long-path targets are not truncated.
+ ///
+ private const int TARGET_PATH_CAPACITY = 1024;
///
/// Real shortcuts are a few KB. Anything larger is not one, and handing it to the shell parser
/// whole would let a dropped file spike memory.
///
- private const int MAX_SHORTCUT_SIZE = 1024 * 1024;
-
- /// SLGP_RAWPATH: retrieve the stored target path without resolving it against the current shell state.
- private const uint SLGP_RAWPATH = 0x00000004;
+ internal const int MAX_SHORTCUT_SIZE = 1024 * 1024;
/// STGM_READ: open the shortcut file read-only.
- private const uint STGM_READ = 0x00000000;
+ private const int STGM_READ = 0;
#endregion
@@ -53,7 +59,7 @@ internal static class ShortcutResolver
#region Public methods
///
- /// Resolves to the file a shortcut points at.
+ /// Resolves to the file a shortcut points at (one hop).
///
/// A path that may or may not be a shortcut.
///
@@ -76,11 +82,11 @@ public static string Resolve (string fileName)
return fileName;
}
- IShellLink shellLink = (IShellLink)new ShellLinkClass();
+ IShellLinkW shellLink = (IShellLinkW)new CShellLinkW();
((IPersistFile)shellLink).Load(fileName, STGM_READ);
- var buffer = new System.Text.StringBuilder(TARGET_PATH_CAPACITY);
- shellLink.GetPath(buffer, buffer.Capacity, IntPtr.Zero, SLGP_RAWPATH);
+ var buffer = new StringBuilder(TARGET_PATH_CAPACITY);
+ shellLink.GetPath(buffer, buffer.Capacity, out _, SLGP.SLGP_RAWPATH);
var target = buffer.ToString();
if (target.Length == 0)
@@ -89,25 +95,29 @@ public static string Resolve (string fileName)
return fileName;
}
- // A UNC target would make File.Exists authenticate against the remote host, handing it
- // the user's NetNTLM hash, and device paths (\\.\, \\?\) can block. Such a shortcut
- // opens as a regular file instead.
- if (target.StartsWith(@"\\", StringComparison.Ordinal))
+ // A relative target sits next to the shortcut, not in the current working directory.
+ var shortcutDirectory = Path.GetDirectoryName(Path.GetFullPath(fileName))!;
+
+ // The target string comes straight from the .lnk bytes, so it can be a UNC or device
+ // path in disguise (//host/share, \\?\UNC\...). Only a plain local drive path is
+ // followed, because probing anything else would authenticate against a remote host,
+ // handing it the user's NetNTLM hash.
+ var fullTarget = SanitizeLocalTarget(target, shortcutDirectory);
+ if (fullTarget is null)
{
- _logger.Debug("Shortcut target {Target} is not a local path, keeping {Shortcut}", target, fileName);
+ _logger.Debug("Shortcut target {Target} is not a local drive path, keeping {Shortcut}", target, fileName);
return fileName;
}
- if (!File.Exists(target))
+ if (!File.Exists(fullTarget))
{
_logger.Debug("Shortcut target {Target} does not exist, keeping {Shortcut}", target, fileName);
return fileName;
}
- return target;
+ return fullTarget;
}
- catch (Exception ex) when (ex is COMException
- or ExternalException
+ catch (Exception ex) when (ex is ExternalException
or IOException
or UnauthorizedAccessException
or ArgumentException
@@ -120,41 +130,34 @@ or ArgumentException
#endregion
- #region COM declarations
-
- /// The shell link CoClass (CLSID_ShellLink).
- [ComImport]
- [Guid("00021401-0000-0000-C000-000000000046")]
- private class ShellLinkClass
- {
- }
+ #region Internal methods
///
- /// IShellLinkW, declared with only its first method (GetPath). The interface's
- /// remaining methods are never called, so they are left out.
+ /// Turns a raw shortcut target into the full path to follow — but only when it lands on a
+ /// plain local drive (X:\). Environment variables are expanded and relative targets are
+ /// resolved against the shortcut's directory; anything else (UNC, device paths like
+ /// \\?\ or \\.\, malformed strings) yields , because
+ /// probing such a path would authenticate against a remote host and leak the user's NetNTLM
+ /// hash. Pure string handling: no file system access.
///
- [ComImport]
- [Guid("000214F9-0000-0000-C000-000000000046")]
- [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
- private interface IShellLink
+ /// The raw target string from the .lnk.
+ /// Directory the shortcut lives in; the base for relative targets.
+ internal static string? SanitizeLocalTarget (string target, string shortcutDirectory)
{
- void GetPath ([MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder pszFile, int cchMaxPath, IntPtr pFindData, uint fFlags);
- }
-
- /// IPersistFile, used to load the shortcut file into the shell link object.
- [ComImport]
- [Guid("0000010B-0000-0000-C000-000000000046")]
- [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
- private interface IPersistFile
- {
- // From IPersist, which IPersistFile extends.
- void GetClassID (out Guid pClassID);
-
- // IsDirty precedes Load in the vtable and has to be declared to keep the slot order.
- [PreserveSig]
- int IsDirty ();
+ try
+ {
+ var expanded = Environment.ExpandEnvironmentVariables(target);
+ var fullTarget = Path.GetFullPath(expanded, shortcutDirectory);
- void Load ([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode);
+ var root = Path.GetPathRoot(fullTarget);
+ return root is not null && root.Length == 3 && root[1] == ':' ? fullTarget : null;
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or IOException
+ or NotSupportedException)
+ {
+ return null;
+ }
}
#endregion
From a4b40e276ba4cd51ec134cf7f17336d11957459b Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Sat, 24 Oct 2026 12:38:05 +0800
Subject: [PATCH 3/6] Follow shortcut chains to the final target with a 3-hop
cap
Shortcut (.lnk) handling now follows a chain of shortcuts to its final
existing local file, bounded by MAX_SHORTCUT_HOPS = 3, and the same
resolution is applied at every entry point (AddFileTab, AddMultiFileTab,
LoadFilesWithOption, ResolveLogFile).
This corrects the earlier PR description:
- resolution is no longer described as a single hop: a valid chain is
followed to its end; a broken, non-local or over-limit chain keeps the
last resolvable path (the original shortcut when the hop cap is hit) so
it still opens as before
- an oversized .lnk (above 1 MiB) is not resolved, but it is not skipped
either; it is opened as the file the user selected
Review follow-ups:
- release the shell link COM object in a finally block
- remove a redundant string.Equals
- document that mapped network drives are intentionally trusted
- resolve before sorting in LoadFilesWithOption so multi-file order follows
the target names
- trim duplicated comments
- rename Sanitize_* tests to SanitizeLocalTarget_*
Chain, limit and cycle behaviour is covered through an injected one-hop
resolver, because the Windows shell dereferences .lnk targets when saving.
All 389 LogExpert.UI tests pass.
---
.../Services/FileOperationServiceTests.cs | 2 +-
.../Services/ShortcutResolverTests.cs | 76 ++++++--
.../Services/TestShortcuts.cs | 4 -
.../FileOperationService.cs | 6 +-
.../FileOperationService/ShortcutResolver.cs | 168 +++++++++++-------
5 files changed, 165 insertions(+), 91 deletions(-)
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 46b67a32..899386ae 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -205,7 +205,7 @@ public void AddFileTab_RaisesFileOpenedEvent ()
// Assert
Assert.That(receivedArgs, Is.Not.Null);
Assert.That(receivedArgs!.LogWindow, Is.SameAs(_stubLogWindow));
- Assert.That(receivedArgs.Request, Is.SameAs(request));
+ Assert.That(receivedArgs.Request, Is.EqualTo(request));
Assert.That(receivedArgs.ResolvedFileName, Is.Not.Null.And.Not.Empty);
Assert.That(receivedArgs.EncodingOptions, Is.Not.Null);
}
diff --git a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
index 9a2d5aa5..c46f87cf 100644
--- a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
+++ b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
@@ -6,14 +6,6 @@
namespace LogExpert.UI.Tests.Services;
-///
-/// Tests the guard rails of : end to end via
-/// for whole-file behaviour (real shortcuts, oversized
-/// files, plain UNC targets), and directly on
-/// for disguised target forms — the shell parser cannot be fed strings like
-/// //host/share through any API a test could use, but the guard must reject them, so it is
-/// written as a pure function and tested as one.
-///
[TestFixture]
[SupportedOSPlatform("windows")]
internal class ShortcutResolverTests
@@ -76,33 +68,79 @@ public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
}
}
+ // The shell resolves .lnk targets when saving, so a real chain cannot be produced with
+ // WScript.Shell; the follower is exercised with injected hops instead.
+ [Test]
+ public void FollowChain_ShortcutToShortcut_ReturnsFinalTarget ()
+ {
+ var targets = new Dictionary
+ {
+ [@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
+ [@"C:\logs\second.lnk"] = @"C:\logs\actual.log"
+ };
+
+ Assert.That(ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path)),
+ Is.EqualTo(@"C:\logs\actual.log"));
+ }
+
+ [Test]
+ public void FollowChain_ChainAtLimit_ReturnsFinalTarget ()
+ {
+ var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS);
+
+ Assert.That(ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path)),
+ Is.EqualTo(@"C:\logs\actual.log"));
+ }
+
+ [Test]
+ public void FollowChain_ChainBeyondLimit_StopsAfterMaxHops ()
+ {
+ var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS + 1);
+
+ Assert.That(ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path)),
+ Is.EqualTo(@"C:\logs\link1.lnk"));
+ }
+
+ [Test]
+ public void FollowChain_CyclicChain_StopsAfterMaxHops ()
+ {
+ var targets = new Dictionary
+ {
+ [@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
+ [@"C:\logs\second.lnk"] = @"C:\logs\first.lnk"
+ };
+
+ Assert.That(ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path)),
+ Is.EqualTo(@"C:\logs\first.lnk"));
+ }
+
[TestCase("//nonexistent-host/share/actual.log")]
[TestCase(@"\\?\UNC\nonexistent-host\share\actual.log")]
[TestCase(@"\\?\C:\Windows\system32\drivers\etc\hosts")]
[TestCase(@"\\.\C:\nonexistent\actual.log")]
[TestCase(@"\??\UNC\nonexistent-host\share\actual.log")]
- public void Sanitize_DisguisedNonLocalTarget_IsRejected (string target)
+ public void SanitizeLocalTarget_DisguisedNonLocalTarget_IsRejected (string target)
{
Assert.That(ShortcutResolver.SanitizeLocalTarget(target, @"C:\logs"), Is.Null,
$"'{target}' must not be followed: probing it could reach the network");
}
[Test]
- public void Sanitize_PlainLocalTarget_ReturnsFullPath ()
+ public void SanitizeLocalTarget_PlainLocalTarget_ReturnsFullPath ()
{
Assert.That(ShortcutResolver.SanitizeLocalTarget(@"C:\logs\actual.log", @"D:\other"),
Is.EqualTo(@"C:\logs\actual.log"));
}
[Test]
- public void Sanitize_RelativeTarget_ResolvesAgainstShortcutDirectory ()
+ public void SanitizeLocalTarget_RelativeTarget_ResolvesAgainstShortcutDirectory ()
{
Assert.That(ShortcutResolver.SanitizeLocalTarget(@"sub\actual.log", @"C:\logs"),
Is.EqualTo(@"C:\logs\sub\actual.log"));
}
[Test]
- public void Sanitize_EnvironmentVariableTarget_IsExpanded ()
+ public void SanitizeLocalTarget_EnvironmentVariableTarget_IsExpanded ()
{
Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", @"C:\logs", EnvironmentVariableTarget.Process);
@@ -117,6 +155,20 @@ public void Sanitize_EnvironmentVariableTarget_IsExpanded ()
}
}
+ private static Dictionary CreateChain (int linkCount)
+ {
+ var targets = new Dictionary();
+
+ for (var i = 1; i <= linkCount; i++)
+ {
+ targets[$@"C:\logs\link{i}.lnk"] = i == linkCount
+ ? @"C:\logs\actual.log"
+ : $@"C:\logs\link{i + 1}.lnk";
+ }
+
+ return targets;
+ }
+
private static string CreateTempDirectory (string name)
{
var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
diff --git a/src/LogExpert.UI.Tests/Services/TestShortcuts.cs b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
index c29edf27..c919d9b1 100644
--- a/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
+++ b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
@@ -5,10 +5,6 @@
namespace LogExpert.UI.Tests.Services;
-///
-/// Creates real shortcuts through the shell's WScript.Shell automation object, so the tests
-/// exercise the same files Explorer writes. The COM objects are released right after saving.
-///
[SupportedOSPlatform("windows")]
internal static class TestShortcuts
{
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 77fa16d7..4f4dbfc2 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -68,11 +68,7 @@ internal sealed class FileOperationService (
public LogWindow AddFileTab (FileTabRequest request)
{
- var resolvedFile = ShortcutResolver.Resolve(request.FileName);
- if (!string.Equals(resolvedFile, request.FileName, StringComparison.Ordinal))
- {
- request = request with { FileName = resolvedFile };
- }
+ request = request with { FileName = ShortcutResolver.Resolve(request.FileName) };
var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry);
var normalizedPath = Path.GetFullPath(logFileName);
diff --git a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
index 872551d8..e4e020fc 100644
--- a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
@@ -12,21 +12,8 @@
namespace LogExpert.UI.Services.FileOperationService;
///
-/// Resolves a Windows shortcut (.lnk) to the local file it points at.
+/// Resolves a Windows shortcut (.lnk) to the local file at the end of its chain.
///
-///
-///
-/// Resolution goes through the shell's own IShellLink implementation (Vanara's
-/// ) instead of parsing the MS-SHLLINK format by hand. This class lives in
-/// the UI assembly, which is net10.0-windows, so the COM dependency is safe here.
-///
-///
-/// Anything that is not a .lnk, does not exist, is implausibly large, cannot be resolved, or
-/// points off the local machine is returned unchanged: such a shortcut opens as a regular file,
-/// exactly as before. Resolution is one hop: a target that is itself a shortcut is returned as-is
-/// and resolved by the next caller.
-///
-///
[SupportedOSPlatform("windows")]
internal static class ShortcutResolver
{
@@ -34,18 +21,13 @@ internal static class ShortcutResolver
private const string SHORTCUT_EXTENSION = ".lnk";
- ///
- /// Buffer size for IShellLinkW::GetPath, above MAX_PATH so long-path targets are not truncated.
- ///
private const int TARGET_PATH_CAPACITY = 1024;
- ///
- /// Real shortcuts are a few KB. Anything larger is not one, and handing it to the shell parser
- /// whole would let a dropped file spike memory.
- ///
internal const int MAX_SHORTCUT_SIZE = 1024 * 1024;
- /// STGM_READ: open the shortcut file read-only.
+ // A chain is followed to its end, but a broken or cyclic chain must not load COM objects forever.
+ internal const int MAX_SHORTCUT_HOPS = 3;
+
private const int STGM_READ = 0;
#endregion
@@ -59,30 +41,105 @@ internal static class ShortcutResolver
#region Public methods
///
- /// Resolves to the file a shortcut points at (one hop).
+ /// Follows through at most shortcuts.
///
- /// A path that may or may not be a shortcut.
///
- /// The target path when it exists; otherwise unchanged.
+ /// The final existing target; otherwise the last path that could not be followed (the input
+ /// itself when the hop limit is reached).
///
public static string Resolve (string fileName)
{
- if (string.IsNullOrWhiteSpace(fileName)
- || !Path.GetExtension(fileName).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase)
- || !File.Exists(fileName))
+ return FollowChain(fileName, ResolveOneHop);
+ }
+
+ #endregion
+
+ #region Internal methods
+
+ internal static string FollowChain (string fileName, Func resolveOneHop)
+ {
+ var current = fileName;
+
+ for (var hop = 0; ; hop++)
{
- return fileName;
+ if (!IsShortcut(current))
+ {
+ return current;
+ }
+
+ if (hop >= MAX_SHORTCUT_HOPS)
+ {
+ // Keep the input, not the partially followed path: another call site resolving
+ // the result again would otherwise continue past the limit.
+ _logger.Warn("Shortcut chain starting at {Shortcut} exceeds {Limit} hops; keeping it", fileName, MAX_SHORTCUT_HOPS);
+ return fileName;
+ }
+
+ var target = resolveOneHop(current);
+ if (target is null)
+ {
+ return current;
+ }
+
+ current = target;
+ }
+ }
+
+ ///
+ /// Normalizes a raw shortcut target and returns it only when it is a plain local drive path
+ /// (X:\); anything that could reach the network yields .
+ ///
+ internal static string? SanitizeLocalTarget (string target, string shortcutDirectory)
+ {
+ try
+ {
+ var expanded = Environment.ExpandEnvironmentVariables(target);
+ var fullTarget = Path.GetFullPath(expanded, shortcutDirectory);
+
+ // Mapped drives are allowed on purpose: the user created the mapping, so it is trusted.
+ var root = Path.GetPathRoot(fullTarget);
+ return root is not null && root.Length == 3 && root[1] == ':' ? fullTarget : null;
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or IOException
+ or NotSupportedException)
+ {
+ return null;
}
+ }
+
+ #endregion
+
+ #region Private methods
+
+ private static bool IsShortcut (string fileName)
+ {
+ return !string.IsNullOrWhiteSpace(fileName)
+ && Path.GetExtension(fileName).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Resolves a single shortcut hop, or returns when it cannot be followed.
+ ///
+ private static string? ResolveOneHop (string fileName)
+ {
+ IShellLinkW? shellLink = null;
try
{
+ if (!File.Exists(fileName))
+ {
+ _logger.Debug("Shortcut {Shortcut} does not exist, keeping it", fileName);
+ return null;
+ }
+
if (new FileInfo(fileName).Length > MAX_SHORTCUT_SIZE)
{
_logger.Warn("Ignoring {Shortcut}: it is above the {Limit} byte shortcut limit", fileName, MAX_SHORTCUT_SIZE);
- return fileName;
+ return null;
}
- IShellLinkW shellLink = (IShellLinkW)new CShellLinkW();
+ shellLink = (IShellLinkW)new CShellLinkW();
((IPersistFile)shellLink).Load(fileName, STGM_READ);
var buffer = new StringBuilder(TARGET_PATH_CAPACITY);
@@ -92,27 +149,25 @@ public static string Resolve (string fileName)
if (target.Length == 0)
{
_logger.Debug("Shortcut {Shortcut} has no target path, keeping it", fileName);
- return fileName;
+ return null;
}
// A relative target sits next to the shortcut, not in the current working directory.
var shortcutDirectory = Path.GetDirectoryName(Path.GetFullPath(fileName))!;
- // The target string comes straight from the .lnk bytes, so it can be a UNC or device
- // path in disguise (//host/share, \\?\UNC\...). Only a plain local drive path is
- // followed, because probing anything else would authenticate against a remote host,
- // handing it the user's NetNTLM hash.
+ // The target comes straight from the .lnk bytes, so only follow a plain local drive path:
+ // probing a UNC/device path would authenticate against a remote host (NetNTLM leak).
var fullTarget = SanitizeLocalTarget(target, shortcutDirectory);
if (fullTarget is null)
{
_logger.Debug("Shortcut target {Target} is not a local drive path, keeping {Shortcut}", target, fileName);
- return fileName;
+ return null;
}
if (!File.Exists(fullTarget))
{
_logger.Debug("Shortcut target {Target} does not exist, keeping {Shortcut}", target, fileName);
- return fileName;
+ return null;
}
return fullTarget;
@@ -124,39 +179,14 @@ or ArgumentException
or NotSupportedException)
{
_logger.Debug(ex, "Could not resolve shortcut {Shortcut}, keeping it", fileName);
- return fileName;
- }
- }
-
- #endregion
-
- #region Internal methods
-
- ///
- /// Turns a raw shortcut target into the full path to follow — but only when it lands on a
- /// plain local drive (X:\). Environment variables are expanded and relative targets are
- /// resolved against the shortcut's directory; anything else (UNC, device paths like
- /// \\?\ or \\.\, malformed strings) yields , because
- /// probing such a path would authenticate against a remote host and leak the user's NetNTLM
- /// hash. Pure string handling: no file system access.
- ///
- /// The raw target string from the .lnk.
- /// Directory the shortcut lives in; the base for relative targets.
- internal static string? SanitizeLocalTarget (string target, string shortcutDirectory)
- {
- try
- {
- var expanded = Environment.ExpandEnvironmentVariables(target);
- var fullTarget = Path.GetFullPath(expanded, shortcutDirectory);
-
- var root = Path.GetPathRoot(fullTarget);
- return root is not null && root.Length == 3 && root[1] == ':' ? fullTarget : null;
+ return null;
}
- catch (Exception ex) when (ex is ArgumentException
- or IOException
- or NotSupportedException)
+ finally
{
- return null;
+ if (shellLink is not null)
+ {
+ Marshal.FinalReleaseComObject(shellLink);
+ }
}
}
From bd755d0623132e24041a9d75526a3a31057e7e8c Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Wed, 28 Oct 2026 11:18:22 +0800
Subject: [PATCH 4/6] Resolve shortcuts once per entry point
Add resolved-name tab creation paths so every entry point resolves a
shortcut exactly once. A single dropped shortcut used to be resolved
three times (up to 9 COM loads and 3 cap warnings); it is now resolved
once. AddMultiFileTab reuses its already-resolved names when splitting
out .lxj files.
Share temp-directory setup through TestShortcuts, remove the helper doc
comments called out in review, and mark the new shortcut tests with
Arrange/Act/Assert.
---
.../Services/FileOperationServiceTests.cs | 33 ++++++++----------
.../Services/ShortcutResolverTests.cs | 13 ++-----
.../Services/TestShortcuts.cs | 16 ++++++---
.../FileOperationService.cs | 34 +++++++------------
.../FileOperationService/ShortcutResolver.cs | 10 ------
5 files changed, 41 insertions(+), 65 deletions(-)
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 899386ae..6d1d1f8b 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -509,8 +509,7 @@ public void LoadDroppedFiles_Ask_UsesOnlyTheConfirmedSelection (MultiFileDecisio
[TestCase(1, true, 1)]
public void LoadDroppedFiles_CombinedChoice_ReadsAccessibleSelectedFiles (int unavailableIndex, bool lockFile, int expectedLines)
{
- var directory = Path.Join(Path.GetTempPath(), "LogExpertDropRouting", Guid.NewGuid().ToString());
- _ = Directory.CreateDirectory(directory);
+ var directory = TestShortcuts.CreateTempDirectory("DropRouting");
var first = Path.Join(directory, "a.log");
var second = Path.Join(directory, "b.txt");
File.WriteAllText(first, "first\n");
@@ -575,8 +574,7 @@ public void LoadDroppedFiles_UnavailableFile_DoesNotBlockRemainingSelection (boo
[Test]
public void LoadDroppedFiles_CombinedSessionAndLog_DoesNotOpenUnselectedRotations ()
{
- var directory = Path.Join(Path.GetTempPath(), "LogExpertDropRouting", Guid.NewGuid().ToString());
- _ = Directory.CreateDirectory(directory);
+ var directory = TestShortcuts.CreateTempDirectory("DropRouting");
var log = Path.Join(directory, "b.log");
var session = Path.Join(directory, "session.lxj");
File.WriteAllText(log, "selected\n");
@@ -606,8 +604,7 @@ public void LoadDroppedFiles_CombinedSessionAndLog_DoesNotOpenUnselectedRotation
[Test]
public void LoadDroppedFiles_CombinedSelectionAfterTruncation_KeepsOnlySelectedPaths ()
{
- var directory = Path.Join(Path.GetTempPath(), "LogExpertDropRouting", Guid.NewGuid().ToString());
- _ = Directory.CreateDirectory(directory);
+ var directory = TestShortcuts.CreateTempDirectory("DropRouting");
var first = Path.Join(directory, "a.log");
var second = Path.Join(directory, "b.log");
File.WriteAllText(first, "first\n");
@@ -660,13 +657,15 @@ public void LoadFilesWithOption_SingleFile_CallsAddFileTab ()
[Test]
public void LoadFilesWithOption_ShortcutToLxjFile_CallsProjectCallback ()
{
- // The .lxj check has to run on the resolved name, or the shortcut gets opened as a raw log.
+ // Arrange — the .lxj check has to run on the resolved name, or the shortcut gets opened as a raw log.
var (directory, shortcut, session) = CreateShortcutToSession();
try
{
+ // Act
var decision = _sut.LoadFilesWithOption(new[] { shortcut }, false);
+ // Assert
Assert.That(decision, Is.EqualTo(MultiFileDecision.Cancel));
Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
@@ -681,12 +680,15 @@ public void LoadFilesWithOption_ShortcutToLxjFile_CallsProjectCallback ()
[Test]
public void AddFileTabs_ShortcutToLxjFile_CallsProjectCallback ()
{
+ // Arrange
var (directory, shortcut, session) = CreateShortcutToSession();
try
{
+ // Act
_sut.AddFileTabs(new[] { shortcut });
+ // Assert
Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log");
@@ -700,8 +702,8 @@ public void AddFileTabs_ShortcutToLxjFile_CallsProjectCallback ()
[Test]
public void AddFileTab_ShortcutToLogFile_OpensTarget ()
{
- var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShortcutLog", Guid.NewGuid().ToString());
- _ = Directory.CreateDirectory(directory);
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutLog");
try
{
@@ -711,8 +713,10 @@ public void AddFileTab_ShortcutToLogFile_OpensTarget ()
var shortcut = Path.Join(directory, "actual.lnk");
TestShortcuts.Create(shortcut, log);
+ // Act
_sut.AddFileTab(new FileTabRequest { FileName = shortcut });
+ // Assert
Assert.That(_factoryCalls, Has.Count.EqualTo(1));
Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo(log), "the target file is opened, not the shortcut");
}
@@ -1290,14 +1294,9 @@ public void LoadFiles_DelegatesToAddFileTabs ()
Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("b.log"));
}
- ///
- /// Creates a temporary directory holding a session file and a shortcut to it, and returns the
- /// three paths. The caller owns the directory and deletes it.
- ///
private static (string Directory, string Shortcut, string Session) CreateShortcutToSession ()
{
- var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShortcutSession", Guid.NewGuid().ToString());
- _ = Directory.CreateDirectory(directory);
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutSession");
var session = Path.Join(directory, "project.lxj");
File.WriteAllText(session, "");
@@ -1308,10 +1307,6 @@ private static (string Directory, string Shortcut, string Session) CreateShortcu
return (directory, shortcut, session);
}
- ///
- /// Builds a second service on the shared config manager, standing in for another LogTabWindow
- /// of the same session with its own tab controller.
- ///
private FileOperationService CreateServiceForWindows (params LogWindow[] windows)
{
var tabControllerMock = new Mock();
diff --git a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
index c46f87cf..783f56ac 100644
--- a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
+++ b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
@@ -13,7 +13,7 @@ internal class ShortcutResolverTests
[Test]
public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
{
- var directory = CreateTempDirectory("ShortcutLocal");
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutLocal");
try
{
@@ -34,7 +34,7 @@ public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
[Test]
public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
{
- var directory = CreateTempDirectory("ShortcutUnc");
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutUnc");
try
{
@@ -53,7 +53,7 @@ public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
[Test]
public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
{
- var directory = CreateTempDirectory("ShortcutOversized");
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutOversized");
try
{
@@ -168,11 +168,4 @@ private static Dictionary CreateChain (int linkCount)
return targets;
}
-
- private static string CreateTempDirectory (string name)
- {
- var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
- _ = Directory.CreateDirectory(directory);
- return directory;
- }
}
diff --git a/src/LogExpert.UI.Tests/Services/TestShortcuts.cs b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
index c919d9b1..8b2cd218 100644
--- a/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
+++ b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs
@@ -8,25 +8,31 @@ namespace LogExpert.UI.Tests.Services;
[SupportedOSPlatform("windows")]
internal static class TestShortcuts
{
+ public static string CreateTempDirectory (string name)
+ {
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
+ return directory;
+ }
+
public static void Create (string shortcutPath, string targetPath)
{
var shellType = Type.GetTypeFromProgID("WScript.Shell");
Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");
dynamic shell = Activator.CreateInstance(shellType)!;
- object? shortcut = null;
+ dynamic? link = null;
try
{
- dynamic link = shell.CreateShortcut(shortcutPath);
- shortcut = link;
+ link = shell.CreateShortcut(shortcutPath);
link.TargetPath = targetPath;
link.Save();
}
finally
{
- if (shortcut != null)
+ if (link != null)
{
- Marshal.FinalReleaseComObject(shortcut);
+ Marshal.FinalReleaseComObject(link);
}
Marshal.FinalReleaseComObject(shell);
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 4f4dbfc2..096391a4 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -68,8 +68,11 @@ internal sealed class FileOperationService (
public LogWindow AddFileTab (FileTabRequest request)
{
- request = request with { FileName = ShortcutResolver.Resolve(request.FileName) };
+ return AddResolvedFileTab(request with { FileName = ShortcutResolver.Resolve(request.FileName) });
+ }
+ private LogWindow AddResolvedFileTab (FileTabRequest request)
+ {
var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry);
var normalizedPath = Path.GetFullPath(logFileName);
@@ -201,13 +204,13 @@ public LogWindow AddTempFileTab (string fileName, string title)
public LogWindow? AddMultiFileTab (string[] fileNames)
{
- if (fileNames.Length < 1)
- {
- return null;
- }
-
- fileNames = fileNames.Select(ShortcutResolver.Resolve).ToArray();
+ return fileNames.Length < 1
+ ? null
+ : AddResolvedMultiFileTab(fileNames.Select(ShortcutResolver.Resolve).ToArray());
+ }
+ private LogWindow? AddResolvedMultiFileTab (string[] fileNames)
+ {
// Session files (.lxj) aren't logs — dispatch them to the project loader and
// continue with only the log files. Without this, the .lxj would be treated as
// a raw log line in the multi-file tab.
@@ -216,7 +219,7 @@ public LogWindow AddTempFileTab (string fileName, string title)
AddFileTabs(fileNames.Where(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray());
var logs = fileNames.Where(f => !f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray();
- return logs.Length > 0 ? AddMultiFileTab(logs) : null;
+ return logs.Length > 0 ? AddResolvedMultiFileTab(logs) : null;
}
EncodingOptions encodingOptions = new();
@@ -270,12 +273,6 @@ public void LoadDroppedFiles (string[] selectedFiles, bool invertLogic, Func
- /// Resolves a possible shortcut and dispatches a resolved session file (.lxj) to the
- /// session loader. Returns the name to open as a log, or when the file
- /// was a session and has been handled.
- ///
private string? ResolveLogFile (string fileName, bool asFirstFile)
{
var resolved = ShortcutResolver.Resolve(fileName);
diff --git a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
index e4e020fc..1e77c167 100644
--- a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
@@ -69,8 +69,6 @@ internal static string FollowChain (string fileName, Func resol
if (hop >= MAX_SHORTCUT_HOPS)
{
- // Keep the input, not the partially followed path: another call site resolving
- // the result again would otherwise continue past the limit.
_logger.Warn("Shortcut chain starting at {Shortcut} exceeds {Limit} hops; keeping it", fileName, MAX_SHORTCUT_HOPS);
return fileName;
}
@@ -85,10 +83,6 @@ internal static string FollowChain (string fileName, Func resol
}
}
- ///
- /// Normalizes a raw shortcut target and returns it only when it is a plain local drive path
- /// (X:\); anything that could reach the network yields .
- ///
internal static string? SanitizeLocalTarget (string target, string shortcutDirectory)
{
try
@@ -118,9 +112,6 @@ private static bool IsShortcut (string fileName)
&& Path.GetExtension(fileName).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase);
}
- ///
- /// Resolves a single shortcut hop, or returns when it cannot be followed.
- ///
private static string? ResolveOneHop (string fileName)
{
IShellLinkW? shellLink = null;
@@ -152,7 +143,6 @@ private static bool IsShortcut (string fileName)
return null;
}
- // A relative target sits next to the shortcut, not in the current working directory.
var shortcutDirectory = Path.GetDirectoryName(Path.GetFullPath(fileName))!;
// The target comes straight from the .lnk bytes, so only follow a plain local drive path:
From 9daeddfc33949cfb2880025b1d400e2db377e0e4 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Fri, 30 Oct 2026 23:29:25 +0800
Subject: [PATCH 5/6] Sort resolved multi-file tabs and report unfollowable
shortcuts
Review follow-ups:
- sort the already-resolved names in AddResolvedMultiFileTab, so multi-file
order follows the target names again on every entry path (drop, command
line, Ask -> MultiFile)
- tell the user when a shortcut cannot be followed and is opened as a file:
FileOpenedEventArgs carries the unresolved shortcuts, LogTabWindow reports
them once per batch and once per shortcut, and the de/zh-CN resources are
updated
- add Arrange/Act/Assert to ShortcutResolverTests and drop the XML docs from
the internal resolver class
- restore the unrelated edits in FileOperationServiceTests: the temp
directory setup of the three LoadDroppedFiles_* tests and the two helper
docs
All 392 LogExpert.UI tests pass.
---
src/LogExpert.Resources/Resources.Designer.cs | 20 ++++
src/LogExpert.Resources/Resources.de.resx | 8 ++
src/LogExpert.Resources/Resources.resx | 8 ++
src/LogExpert.Resources/Resources.zh-CN.resx | 8 ++
.../Services/FileOperationServiceTests.cs | 110 +++++++++++++++++-
.../Services/ShortcutResolverTests.cs | 90 +++++++++++---
.../Dialogs/LogTabWindow/LogTabWindow.cs | 65 +++++++++++
.../FileOpenedEventArgs.cs | 6 +
.../FileOperationService.cs | 13 ++-
.../FileOperationService/ShortcutResolver.cs | 9 +-
10 files changed, 307 insertions(+), 30 deletions(-)
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index 5e9f174d..0a707007 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -2167,6 +2167,26 @@ public static string LogTabWindow_UI_Message_PluginTrustConfigurationUpdate {
}
}
+ ///
+ /// Looks up a localized string similar to Couldn't follow the shortcut, so it was opened as a file:
+ /// {0}.
+ ///
+ public static string LogTabWindow_UI_Message_UnresolvedShortcut_Single {
+ get {
+ return ResourceManager.GetString("LogTabWindow_UI_Message_UnresolvedShortcut_Single", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Couldn't follow these shortcuts, so they were opened as files:
+ /// {0}.
+ ///
+ public static string LogTabWindow_UI_Message_UnresolvedShortcut_Multiple {
+ get {
+ return ResourceManager.GetString("LogTabWindow_UI_Message_UnresolvedShortcut_Multiple", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to LogExpert session {0}.
///
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 736bc8a4..83211409 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -2093,6 +2093,14 @@ Fortfahren?
Plugin-Vertrauenskonfiguration aktualisiert.
LogExpert neu starten, um die Änderungen zu übernehmen?
+
+
+ Die Verknüpfung konnte nicht aufgelöst werden und wurde als Datei geöffnet:
+{0}
+
+
+ Die folgenden Verknüpfungen konnten nicht aufgelöst werden und wurden als Dateien geöffnet:
+{0}
{0} nicht initialisiert
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index 441e4b87..09c0793c 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -2094,6 +2094,14 @@ Continue?
Plugin trust configuration updated.
Restart LogExpert to apply changes?
+
+
+ Couldn't follow the shortcut, so it was opened as a file:
+{0}
+
+
+ Couldn't follow these shortcuts, so they were opened as files:
+{0}
Restart Recommended
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index 58f62e03..816cab37 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -1906,6 +1906,14 @@ YY[YY] = 年
插件信任配置已更新。
是否重启 LogExpert 以应用更改?
+
+
+ 无法跟随该快捷方式,已按文件打开:
+{0}
+
+
+ 无法跟随以下快捷方式,已按文件打开:
+{0}
建议重启
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 6d1d1f8b..a954e6f7 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -509,7 +509,8 @@ public void LoadDroppedFiles_Ask_UsesOnlyTheConfirmedSelection (MultiFileDecisio
[TestCase(1, true, 1)]
public void LoadDroppedFiles_CombinedChoice_ReadsAccessibleSelectedFiles (int unavailableIndex, bool lockFile, int expectedLines)
{
- var directory = TestShortcuts.CreateTempDirectory("DropRouting");
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertDropRouting", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
var first = Path.Join(directory, "a.log");
var second = Path.Join(directory, "b.txt");
File.WriteAllText(first, "first\n");
@@ -574,7 +575,8 @@ public void LoadDroppedFiles_UnavailableFile_DoesNotBlockRemainingSelection (boo
[Test]
public void LoadDroppedFiles_CombinedSessionAndLog_DoesNotOpenUnselectedRotations ()
{
- var directory = TestShortcuts.CreateTempDirectory("DropRouting");
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertDropRouting", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
var log = Path.Join(directory, "b.log");
var session = Path.Join(directory, "session.lxj");
File.WriteAllText(log, "selected\n");
@@ -604,7 +606,8 @@ public void LoadDroppedFiles_CombinedSessionAndLog_DoesNotOpenUnselectedRotation
[Test]
public void LoadDroppedFiles_CombinedSelectionAfterTruncation_KeepsOnlySelectedPaths ()
{
- var directory = TestShortcuts.CreateTempDirectory("DropRouting");
+ var directory = Path.Join(Path.GetTempPath(), "LogExpertDropRouting", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(directory);
var first = Path.Join(directory, "a.log");
var second = Path.Join(directory, "b.log");
File.WriteAllText(first, "first\n");
@@ -726,6 +729,64 @@ public void AddFileTab_ShortcutToLogFile_OpensTarget ()
}
}
+ [Test]
+ public void AddFileTab_UnresolvableShortcut_ReportsItInFileOpened ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutBroken");
+
+ try
+ {
+ var shortcut = Path.Join(directory, "broken.lnk");
+ TestShortcuts.Create(shortcut, Path.Join(directory, "missing.log"));
+
+ FileOpenedEventArgs? receivedArgs = null;
+ _sut.FileOpened += (_, args) => receivedArgs = args;
+
+ // Act
+ _sut.AddFileTab(new FileTabRequest { FileName = shortcut });
+
+ // Assert
+ Assert.That(receivedArgs, Is.Not.Null);
+ Assert.That(receivedArgs!.UnresolvedShortcuts, Is.EqualTo(new[] { shortcut }),
+ "the user has to be told why the shortcut opens as binary data");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Test]
+ public void AddFileTab_ResolvedShortcut_DoesNotReportUnresolvedShortcut ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutResolved");
+
+ try
+ {
+ var log = Path.Join(directory, "actual.log");
+ File.WriteAllText(log, "line1");
+
+ var shortcut = Path.Join(directory, "actual.lnk");
+ TestShortcuts.Create(shortcut, log);
+
+ FileOpenedEventArgs? receivedArgs = null;
+ _sut.FileOpened += (_, args) => receivedArgs = args;
+
+ // Act
+ _sut.AddFileTab(new FileTabRequest { FileName = shortcut });
+
+ // Assert
+ Assert.That(receivedArgs, Is.Not.Null);
+ Assert.That(receivedArgs!.UnresolvedShortcuts, Is.Null);
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
[Test]
public void LoadFilesWithOption_SingleLxjFile_CallsProjectCallback ()
{
@@ -880,6 +941,41 @@ public void AddMultiFileTab_RaisesFileOpened_WithMultiFileNames ()
Assert.That(receivedArgs.EncodingOptions, Is.Not.Null);
}
+ [Test]
+ public void AddMultiFileTab_Shortcuts_SortsByResolvedTargetName ()
+ {
+ // Arrange
+ var directory = TestShortcuts.CreateTempDirectory("ShortcutSort");
+
+ try
+ {
+ var firstTarget = Path.Join(directory, "a.log");
+ var secondTarget = Path.Join(directory, "b.log");
+ File.WriteAllText(firstTarget, "first");
+ File.WriteAllText(secondTarget, "second");
+
+ // The shortcut names sort in the opposite order of their targets.
+ var zShortcut = Path.Join(directory, "z.lnk");
+ var aShortcut = Path.Join(directory, "a.lnk");
+ TestShortcuts.Create(zShortcut, firstTarget);
+ TestShortcuts.Create(aShortcut, secondTarget);
+
+ string[]? multiFileNames = null;
+ _sut.FileOpened += (_, e) => multiFileNames = e.MultiFileNames;
+
+ // Act
+ _ = _sut.AddMultiFileTab([zShortcut, aShortcut]);
+
+ // Assert
+ Assert.That(multiFileNames, Is.EqualTo(new[] { firstTarget, secondTarget }),
+ "multi-file order must follow the resolved target names, not the shortcut names");
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
[Test]
public void AddFileTabs_SkipsEmptyStrings ()
{
@@ -1294,6 +1390,10 @@ public void LoadFiles_DelegatesToAddFileTabs ()
Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("b.log"));
}
+ ///
+ /// Creates a temporary directory holding a session file and a shortcut to it, and returns the
+ /// three paths. The caller owns the directory and deletes it.
+ ///
private static (string Directory, string Shortcut, string Session) CreateShortcutToSession ()
{
var directory = TestShortcuts.CreateTempDirectory("ShortcutSession");
@@ -1307,6 +1407,10 @@ private static (string Directory, string Shortcut, string Session) CreateShortcu
return (directory, shortcut, session);
}
+ ///
+ /// Builds a second service on the shared config manager, standing in for another LogTabWindow
+ /// of the same session with its own tab controller.
+ ///
private FileOperationService CreateServiceForWindows (params LogWindow[] windows)
{
var tabControllerMock = new Mock();
diff --git a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
index 783f56ac..14ea7d66 100644
--- a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
+++ b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
@@ -13,6 +13,7 @@ internal class ShortcutResolverTests
[Test]
public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
{
+ // Arrange
var directory = TestShortcuts.CreateTempDirectory("ShortcutLocal");
try
@@ -23,7 +24,11 @@ public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
var shortcut = Path.Join(directory, "actual.lnk");
TestShortcuts.Create(shortcut, log);
- Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(log));
+ // Act
+ var resolved = ShortcutResolver.Resolve(shortcut);
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(log));
}
finally
{
@@ -34,6 +39,7 @@ public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
[Test]
public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
{
+ // Arrange
var directory = TestShortcuts.CreateTempDirectory("ShortcutUnc");
try
@@ -41,7 +47,11 @@ public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
var shortcut = Path.Join(directory, "remote.lnk");
TestShortcuts.Create(shortcut, @"\\nonexistent-host\share\actual.log");
- Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut),
+ // Act
+ var resolved = ShortcutResolver.Resolve(shortcut);
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(shortcut),
"a UNC target must not be probed: File.Exists would authenticate against the remote host");
}
finally
@@ -53,6 +63,7 @@ public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
[Test]
public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
{
+ // Arrange
var directory = TestShortcuts.CreateTempDirectory("ShortcutOversized");
try
@@ -60,7 +71,11 @@ public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
var shortcut = Path.Join(directory, "oversized.lnk");
File.WriteAllBytes(shortcut, new byte[ShortcutResolver.MAX_SHORTCUT_SIZE + 1]);
- Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ // Act
+ var resolved = ShortcutResolver.Resolve(shortcut);
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(shortcut));
}
finally
{
@@ -73,45 +88,61 @@ public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
[Test]
public void FollowChain_ShortcutToShortcut_ReturnsFinalTarget ()
{
+ // Arrange
var targets = new Dictionary
{
[@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
[@"C:\logs\second.lnk"] = @"C:\logs\actual.log"
};
- Assert.That(ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path)),
- Is.EqualTo(@"C:\logs\actual.log"));
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\actual.log"));
}
[Test]
public void FollowChain_ChainAtLimit_ReturnsFinalTarget ()
{
+ // Arrange
var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS);
- Assert.That(ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path)),
- Is.EqualTo(@"C:\logs\actual.log"));
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\actual.log"));
}
[Test]
public void FollowChain_ChainBeyondLimit_StopsAfterMaxHops ()
{
+ // Arrange
var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS + 1);
- Assert.That(ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path)),
- Is.EqualTo(@"C:\logs\link1.lnk"));
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\link1.lnk"));
}
[Test]
public void FollowChain_CyclicChain_StopsAfterMaxHops ()
{
+ // Arrange
var targets = new Dictionary
{
[@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
[@"C:\logs\second.lnk"] = @"C:\logs\first.lnk"
};
- Assert.That(ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path)),
- Is.EqualTo(@"C:\logs\first.lnk"));
+ // Act
+ var resolved = ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path));
+
+ // Assert
+ Assert.That(resolved, Is.EqualTo(@"C:\logs\first.lnk"));
}
[TestCase("//nonexistent-host/share/actual.log")]
@@ -121,33 +152,56 @@ public void FollowChain_CyclicChain_StopsAfterMaxHops ()
[TestCase(@"\??\UNC\nonexistent-host\share\actual.log")]
public void SanitizeLocalTarget_DisguisedNonLocalTarget_IsRejected (string target)
{
- Assert.That(ShortcutResolver.SanitizeLocalTarget(target, @"C:\logs"), Is.Null,
+ // Arrange
+ const string shortcutDirectory = @"C:\logs";
+
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(target, shortcutDirectory);
+
+ // Assert
+ Assert.That(sanitized, Is.Null,
$"'{target}' must not be followed: probing it could reach the network");
}
[Test]
public void SanitizeLocalTarget_PlainLocalTarget_ReturnsFullPath ()
{
- Assert.That(ShortcutResolver.SanitizeLocalTarget(@"C:\logs\actual.log", @"D:\other"),
- Is.EqualTo(@"C:\logs\actual.log"));
+ // Arrange
+ const string target = @"C:\logs\actual.log";
+
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(target, @"D:\other");
+
+ // Assert
+ Assert.That(sanitized, Is.EqualTo(target));
}
[Test]
public void SanitizeLocalTarget_RelativeTarget_ResolvesAgainstShortcutDirectory ()
{
- Assert.That(ShortcutResolver.SanitizeLocalTarget(@"sub\actual.log", @"C:\logs"),
- Is.EqualTo(@"C:\logs\sub\actual.log"));
+ // Arrange
+ const string target = @"sub\actual.log";
+
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(target, @"C:\logs");
+
+ // Assert
+ Assert.That(sanitized, Is.EqualTo(@"C:\logs\sub\actual.log"));
}
[Test]
public void SanitizeLocalTarget_EnvironmentVariableTarget_IsExpanded ()
{
+ // Arrange
Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", @"C:\logs", EnvironmentVariableTarget.Process);
try
{
- Assert.That(ShortcutResolver.SanitizeLocalTarget(@"%LOGEXPERT_TEST_VAR%\actual.log", @"D:\other"),
- Is.EqualTo(@"C:\logs\actual.log"));
+ // Act
+ var sanitized = ShortcutResolver.SanitizeLocalTarget(@"%LOGEXPERT_TEST_VAR%\actual.log", @"D:\other");
+
+ // Assert
+ Assert.That(sanitized, Is.EqualTo(@"C:\logs\actual.log"));
}
finally
{
diff --git a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
index 3b1218b3..e76c7394 100644
--- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
+++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
@@ -80,6 +80,12 @@ internal partial class LogTabWindow : Form, ILogTabWindow
private bool _wasMaximized;
+ // Unfollowable shortcuts are reported once per batch and once per shortcut, so dropping a
+ // folder of broken links does not stack message boxes.
+ private readonly HashSet _reportedUnresolvedShortcuts = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _pendingUnresolvedShortcuts = [];
+ private bool _unresolvedShortcutMessageQueued;
+
#endregion
#region cTor
@@ -230,6 +236,65 @@ private void OnFileOperationServiceFileOpened (object? sender, FileOpenedEventAr
multiFileEnabledStripMenuItem.Checked = true;
_ = BeginInvoke(e.LogWindow.LoadFilesAsMulti, e.MultiFileNames, e.EncodingOptions);
}
+
+ if (e.UnresolvedShortcuts is { Length: > 0 })
+ {
+ QueueUnresolvedShortcutMessage(e.UnresolvedShortcuts);
+ }
+ }
+
+ private void QueueUnresolvedShortcutMessage (string[] shortcuts)
+ {
+ foreach (var shortcut in shortcuts)
+ {
+ if (!_reportedUnresolvedShortcuts.Contains(shortcut)
+ && !_pendingUnresolvedShortcuts.Contains(shortcut, StringComparer.OrdinalIgnoreCase))
+ {
+ _pendingUnresolvedShortcuts.Add(shortcut);
+ }
+ }
+
+ if (_pendingUnresolvedShortcuts.Count == 0 || _unresolvedShortcutMessageQueued || _disposed)
+ {
+ return;
+ }
+
+ _unresolvedShortcutMessageQueued = true;
+
+ if (IsHandleCreated)
+ {
+ _ = BeginInvoke(new MethodInvoker(ShowUnresolvedShortcutMessage));
+ }
+ else
+ {
+ ShowUnresolvedShortcutMessage();
+ }
+ }
+
+ private void ShowUnresolvedShortcutMessage ()
+ {
+ _unresolvedShortcutMessageQueued = false;
+
+ var shortcuts = _pendingUnresolvedShortcuts.ToArray();
+ _pendingUnresolvedShortcuts.Clear();
+
+ if (shortcuts.Length == 0 || _disposed)
+ {
+ return;
+ }
+
+ foreach (var shortcut in shortcuts)
+ {
+ _reportedUnresolvedShortcuts.Add(shortcut);
+ }
+
+ var list = string.Join(Environment.NewLine, shortcuts.Select(shortcut => $" • {shortcut}"));
+ var format = shortcuts.Length == 1
+ ? Resources.LogTabWindow_UI_Message_UnresolvedShortcut_Single
+ : Resources.LogTabWindow_UI_Message_UnresolvedShortcut_Multiple;
+
+ _ = MessageBox.Show(this, string.Format(CultureInfo.CurrentCulture, format, list),
+ Resources.LogExpert_Common_UI_Title_LogExpert, MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
private void InitializeMenuToolbarControllerEvents ()
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs b/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs
index dd8b0491..cc840764 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOpenedEventArgs.cs
@@ -33,4 +33,10 @@ internal sealed class FileOpenedEventArgs : EventArgs
/// Set when AddMultiFileTab created this tab. LogTabWindow uses this for BeginInvoke(LoadFilesAsMulti).
public string[]? MultiFileNames { get; init; }
+
+ ///
+ /// Shortcuts that could not be followed and are opened as plain files. LogTabWindow tells the
+ /// user, because such a file opens as binary content with only a log line otherwise.
+ ///
+ public string[]? UnresolvedShortcuts { get; init; }
}
\ No newline at end of file
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 096391a4..b1e7b5ef 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -125,7 +125,8 @@ private LogWindow AddResolvedFileTab (FileTabRequest request)
LogWindow = logWindow,
Request = request,
ResolvedFileName = logFileName,
- EncodingOptions = encodingOptions
+ EncodingOptions = encodingOptions,
+ UnresolvedShortcuts = ShortcutResolver.IsShortcut(request.FileName) ? [request.FileName] : null
});
return logWindow;
@@ -211,6 +212,11 @@ public LogWindow AddTempFileTab (string fileName, string title)
private LogWindow? AddResolvedMultiFileTab (string[] fileNames)
{
+ // Shortcuts are already resolved by the caller. Sorting here keeps multi-file order by
+ // target name on every entry path (drop, command line, Ask → MultiFile, ...) instead of
+ // letting it drift back to shortcut names.
+ Array.Sort(fileNames, StringComparer.OrdinalIgnoreCase);
+
// Session files (.lxj) aren't logs — dispatch them to the project loader and
// continue with only the log files. Without this, the .lxj would be treated as
// a raw log line in the multi-file tab.
@@ -238,13 +244,16 @@ public LogWindow AddTempFileTab (string fileName, string title)
// We raise FileOpened with MultiFileNames so LogTabWindow can call BeginInvoke(logWindow.LoadFilesAsMulti, ...).
AddToFileHistory(fileNames[0]);
+ var unresolvedShortcuts = fileNames.Where(ShortcutResolver.IsShortcut).ToArray();
+
FileOpened?.Invoke(this, new FileOpenedEventArgs
{
LogWindow = logWindow,
Request = request,
ResolvedFileName = fileNames[^1],
EncodingOptions = encodingOptions,
- MultiFileNames = fileNames
+ MultiFileNames = fileNames,
+ UnresolvedShortcuts = unresolvedShortcuts.Length > 0 ? unresolvedShortcuts : null
});
return logWindow;
diff --git a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
index 1e77c167..4ed7be95 100644
--- a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
@@ -11,9 +11,6 @@
namespace LogExpert.UI.Services.FileOperationService;
-///
-/// Resolves a Windows shortcut (.lnk) to the local file at the end of its chain.
-///
[SupportedOSPlatform("windows")]
internal static class ShortcutResolver
{
@@ -40,9 +37,7 @@ internal static class ShortcutResolver
#region Public methods
- ///
- /// Follows through at most shortcuts.
- ///
+ /// Follows through at most shortcuts.
///
/// The final existing target; otherwise the last path that could not be followed (the input
/// itself when the hop limit is reached).
@@ -106,7 +101,7 @@ or IOException
#region Private methods
- private static bool IsShortcut (string fileName)
+ internal static bool IsShortcut (string fileName)
{
return !string.IsNullOrWhiteSpace(fileName)
&& Path.GetExtension(fileName).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase);
From f7bd2bd923afd70fb2c0f06f385bf6d65ca157f8 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Fri, 30 Oct 2026 23:52:03 +0800
Subject: [PATCH 6/6] Use Vanara's STGM enum for the shortcut load mode
Vanara.PInvoke.STGM lives in Vanara.Core, which Shell32 already brings
transitively, so the local STGM_READ constant is no longer needed.
IPersistFile is not exported by any referenced Vanara package; it stays
on System.Runtime.InteropServices.ComTypes.
---
.../Services/FileOperationService/ShortcutResolver.cs | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
index 4ed7be95..7235fd84 100644
--- a/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs
@@ -8,6 +8,7 @@
using IShellLinkW = Vanara.PInvoke.Shell32.IShellLinkW;
using CShellLinkW = Vanara.PInvoke.Shell32.CShellLinkW;
using SLGP = Vanara.PInvoke.Shell32.SLGP;
+using STGM = Vanara.PInvoke.STGM;
namespace LogExpert.UI.Services.FileOperationService;
@@ -25,8 +26,6 @@ internal static class ShortcutResolver
// A chain is followed to its end, but a broken or cyclic chain must not load COM objects forever.
internal const int MAX_SHORTCUT_HOPS = 3;
- private const int STGM_READ = 0;
-
#endregion
#region Fields
@@ -126,7 +125,7 @@ internal static bool IsShortcut (string fileName)
}
shellLink = (IShellLinkW)new CShellLinkW();
- ((IPersistFile)shellLink).Load(fileName, STGM_READ);
+ ((IPersistFile)shellLink).Load(fileName, (int)STGM.STGM_READ);
var buffer = new StringBuilder(TARGET_PATH_CAPACITY);
shellLink.GetPath(buffer, buffer.Capacity, out _, SLGP.SLGP_RAWPATH);