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 2625fb94..a954e6f7 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); } @@ -657,6 +657,136 @@ public void LoadFilesWithOption_SingleFile_CallsAddFileTab () Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("test.log")); } + [Test] + public void LoadFilesWithOption_ShortcutToLxjFile_CallsProjectCallback () + { + // 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)); + Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log"); + } + finally + { + Directory.Delete(directory, true); + } + } + + [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"); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Test] + public void AddFileTab_ShortcutToLogFile_OpensTarget () + { + // Arrange + var directory = TestShortcuts.CreateTempDirectory("ShortcutLog"); + + try + { + var log = Path.Join(directory, "actual.log"); + File.WriteAllText(log, "line1"); + + 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"); + } + finally + { + Directory.Delete(directory, true); + } + } + + [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 () { @@ -811,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 () { @@ -1225,6 +1390,23 @@ 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"); + + var session = Path.Join(directory, "project.lxj"); + File.WriteAllText(session, ""); + + var shortcut = Path.Join(directory, "session.lnk"); + TestShortcuts.Create(shortcut, session); + + 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. diff --git a/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs new file mode 100644 index 00000000..14ea7d66 --- /dev/null +++ b/src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs @@ -0,0 +1,225 @@ +using System.Runtime.Versioning; + +using LogExpert.UI.Services.FileOperationService; + +using NUnit.Framework; + +namespace LogExpert.UI.Tests.Services; + +[TestFixture] +[SupportedOSPlatform("windows")] +internal class ShortcutResolverTests +{ + [Test] + public void Resolve_ShortcutToLocalTarget_ReturnsTarget () + { + // Arrange + var directory = TestShortcuts.CreateTempDirectory("ShortcutLocal"); + + try + { + var log = Path.Join(directory, "actual.log"); + File.WriteAllText(log, "line1"); + + var shortcut = Path.Join(directory, "actual.lnk"); + TestShortcuts.Create(shortcut, log); + + // Act + var resolved = ShortcutResolver.Resolve(shortcut); + + // Assert + Assert.That(resolved, Is.EqualTo(log)); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Test] + public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged () + { + // Arrange + var directory = TestShortcuts.CreateTempDirectory("ShortcutUnc"); + + try + { + var shortcut = Path.Join(directory, "remote.lnk"); + TestShortcuts.Create(shortcut, @"\\nonexistent-host\share\actual.log"); + + // 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 + { + Directory.Delete(directory, true); + } + } + + [Test] + public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged () + { + // Arrange + var directory = TestShortcuts.CreateTempDirectory("ShortcutOversized"); + + try + { + var shortcut = Path.Join(directory, "oversized.lnk"); + File.WriteAllBytes(shortcut, new byte[ShortcutResolver.MAX_SHORTCUT_SIZE + 1]); + + // Act + var resolved = ShortcutResolver.Resolve(shortcut); + + // Assert + Assert.That(resolved, Is.EqualTo(shortcut)); + } + finally + { + Directory.Delete(directory, true); + } + } + + // 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 () + { + // Arrange + var targets = new Dictionary + { + [@"C:\logs\first.lnk"] = @"C:\logs\second.lnk", + [@"C:\logs\second.lnk"] = @"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); + + // 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); + + // 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" + }; + + // 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")] + [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 SanitizeLocalTarget_DisguisedNonLocalTarget_IsRejected (string target) + { + // 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 () + { + // 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 () + { + // 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 + { + // Act + var sanitized = ShortcutResolver.SanitizeLocalTarget(@"%LOGEXPERT_TEST_VAR%\actual.log", @"D:\other"); + + // Assert + Assert.That(sanitized, Is.EqualTo(@"C:\logs\actual.log")); + } + finally + { + Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", null, EnvironmentVariableTarget.Process); + } + } + + 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; + } +} diff --git a/src/LogExpert.UI.Tests/Services/TestShortcuts.cs b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs new file mode 100644 index 00000000..8b2cd218 --- /dev/null +++ b/src/LogExpert.UI.Tests/Services/TestShortcuts.cs @@ -0,0 +1,41 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +using NUnit.Framework; + +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)!; + dynamic? link = null; + try + { + link = shell.CreateShortcut(shortcutPath); + link.TargetPath = targetPath; + link.Save(); + } + finally + { + if (link != null) + { + Marshal.FinalReleaseComObject(link); + } + + Marshal.FinalReleaseComObject(shell); + } + } +} 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 0c72551c..b1e7b5ef 100644 --- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs +++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs @@ -67,6 +67,11 @@ internal sealed class FileOperationService ( // //} public LogWindow AddFileTab (FileTabRequest request) + { + 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); @@ -120,7 +125,8 @@ public LogWindow AddFileTab (FileTabRequest request) LogWindow = logWindow, Request = request, ResolvedFileName = logFileName, - EncodingOptions = encodingOptions + EncodingOptions = encodingOptions, + UnresolvedShortcuts = ShortcutResolver.IsShortcut(request.FileName) ? [request.FileName] : null }); return logWindow; @@ -199,10 +205,17 @@ public LogWindow AddTempFileTab (string fileName, string title) public LogWindow? AddMultiFileTab (string[] fileNames) { - if (fileNames.Length < 1) - { - return null; - } + return fileNames.Length < 1 + ? null + : AddResolvedMultiFileTab(fileNames.Select(ShortcutResolver.Resolve).ToArray()); + } + + 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 @@ -212,7 +225,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(); @@ -231,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; @@ -270,13 +286,13 @@ public MultiFileDecision LoadFilesWithOption (string[] fileNames, bool invertLog if (fileNames.Length == 1) { - if (fileNames[0].EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)) + var logFile = ResolveLogFile(fileNames[0], true); + if (logFile == null) { - _loadSessionCallback(fileNames[0], true); return MultiFileDecision.Cancel; // Already handled } - _ = AddFileTab(new FileTabRequest { FileName = fileNames[0] }); + _ = AddResolvedFileTab(new FileTabRequest { FileName = logFile }); return MultiFileDecision.SingleFiles; } @@ -310,14 +326,13 @@ public void AddFileTabs (string[] fileNames, int? targetLine = null) { try { - if (fileName.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)) - { - _loadSessionCallback(fileName, false); - } - else + var logFile = ResolveLogFile(fileName, false); + if (logFile == null) { - _ = AddFileTab(new FileTabRequest { FileName = fileName, TargetLine = targetLine }); + continue; } + + _ = AddResolvedFileTab(new FileTabRequest { FileName = logFile, TargetLine = targetLine }); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or LogFileException) { @@ -326,6 +341,18 @@ public void AddFileTabs (string[] fileNames, int? targetLine = null) } } + 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 new file mode 100644 index 00000000..7235fd84 --- /dev/null +++ b/src/LogExpert.UI/Services/FileOperationService/ShortcutResolver.cs @@ -0,0 +1,178 @@ +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; +using STGM = Vanara.PInvoke.STGM; + +namespace LogExpert.UI.Services.FileOperationService; + +[SupportedOSPlatform("windows")] +internal static class ShortcutResolver +{ + #region Constants + + private const string SHORTCUT_EXTENSION = ".lnk"; + + private const int TARGET_PATH_CAPACITY = 1024; + + internal const int MAX_SHORTCUT_SIZE = 1024 * 1024; + + // 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; + + #endregion + + #region Fields + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + #endregion + + #region Public methods + + /// 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). + /// + public static string Resolve (string 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++) + { + if (!IsShortcut(current)) + { + return current; + } + + if (hop >= MAX_SHORTCUT_HOPS) + { + _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; + } + } + + 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 + + internal static bool IsShortcut (string fileName) + { + return !string.IsNullOrWhiteSpace(fileName) + && Path.GetExtension(fileName).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase); + } + + 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 null; + } + + shellLink = (IShellLinkW)new CShellLinkW(); + ((IPersistFile)shellLink).Load(fileName, (int)STGM.STGM_READ); + + var buffer = new StringBuilder(TARGET_PATH_CAPACITY); + shellLink.GetPath(buffer, buffer.Capacity, out _, SLGP.SLGP_RAWPATH); + + var target = buffer.ToString(); + if (target.Length == 0) + { + _logger.Debug("Shortcut {Shortcut} has no target path, keeping it", fileName); + return null; + } + + var shortcutDirectory = Path.GetDirectoryName(Path.GetFullPath(fileName))!; + + // 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 null; + } + + if (!File.Exists(fullTarget)) + { + _logger.Debug("Shortcut target {Target} does not exist, keeping {Shortcut}", target, fileName); + return null; + } + + return fullTarget; + } + catch (Exception ex) when (ex is ExternalException + or IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + _logger.Debug(ex, "Could not resolve shortcut {Shortcut}, keeping it", fileName); + return null; + } + finally + { + if (shellLink is not null) + { + Marshal.FinalReleaseComObject(shellLink); + } + } + } + + #endregion +}