From 7402b1595a9bc236f17a5aa6c5f26895dd2a2ff2 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Thu, 22 Oct 2026 18:44:28 +0800
Subject: [PATCH 1/2] feat(core): resolve .lnk shortcuts to their target file
LogExpert opened whatever path it was given as a log. A Windows shortcut
is a binary file, so opening one showed its header bytes as log lines
instead of the log the user meant.
Add ShellLinkResolver, which parses the MS-SHLLINK format directly
rather than going through IShellLink: it lives in LogExpert.Core, which
carries no COM or Windows-only dependency. Candidates are tried most
specific first - LocalBasePath, LocalBasePath + CommonPathSuffix, the
network path, and RelativePath resolved against the shortcut's own
directory. Unicode strings are preferred over their ANSI rendering, and
chained shortcuts are followed up to eight levels.
Wire it in where the reader is fed:
- PersisterHelpers.FindFilenameForSettings resolves before the .lxp
lookup, so a shortcut to a settings file behaves like that file, and a
shortcut to a log inside one resolves through both.
- FileOperationService.AddFileTab reads from and detects .lxp on the
target, so the settings file is found behind the shortcut.
- FileOperationService.AddMultiFileTab resolves the array before it
reaches the reader; without this, only the last file of a multi-file
tab would be resolved.
Unresolvable input - a broken shortcut, a missing target, a shortcut to
a folder - is returned unchanged, so it fails exactly as it did before.
The request keeps the original path, which leaves file history, the
recently used list and the rest of the UI untouched, and paths without
the .lnk extension never touch the disk.
Verified with real shortcuts, absolute, relative-only, ANSI and Unicode
targets, broken links, folder links, chains and loops: dotnet build
src/LogExpert.UI reports 0 warnings and 0 errors, LogExpert.Persister.
Tests passes 105/105, LogExpert.UI.Tests 369/369.
---
.../Classes/Persister/PersisterHelpers.cs | 11 +-
.../Helpers/ShellLinkResolver.cs | 539 ++++++++++++++++++
.../FileOperationService.cs | 24 +-
3 files changed, 564 insertions(+), 10 deletions(-)
create mode 100644 src/LogExpert.Core/Helpers/ShellLinkResolver.cs
diff --git a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
index 24f7894cd..fd1de128e 100644
--- a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
+++ b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
+using LogExpert.Core.Helpers;
using LogExpert.Core.Interfaces;
namespace LogExpert.Core.Classes.Persister;
@@ -10,8 +11,9 @@ public static class PersisterHelpers
private const string LOCAL_FILE_SYSTEM_NAME = "LocalFileSystem";
///
- /// Checks if the file name is a settings file (.lxp). If so, the contained logfile name
- /// is returned. If not, the given file name is returned unchanged.
+ /// Resolves a shortcut to the file it points at, then checks if that file is a settings file
+ /// (.lxp). If so, the contained logfile name is returned. If not, the given file name is
+ /// returned unchanged.
///
/// The file name to resolve
/// Plugin registry for file system resolution (optional)
@@ -20,6 +22,11 @@ public static string FindFilenameForSettings (string fileName, IPluginRegistry p
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName, nameof(fileName));
+ // A shortcut is not a log file: resolve it first, so the rest of this method — the .lxp
+ // lookup included — sees the file the user actually meant. Anything unresolvable comes back
+ // unchanged and keeps the previous behaviour.
+ fileName = ShellLinkResolver.Resolve(fileName);
+
if (fileName.EndsWith(".lxp", StringComparison.OrdinalIgnoreCase))
{
var persistenceData = Persister.Load(fileName);
diff --git a/src/LogExpert.Core/Helpers/ShellLinkResolver.cs b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs
new file mode 100644
index 000000000..390764e36
--- /dev/null
+++ b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs
@@ -0,0 +1,539 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Text;
+
+namespace LogExpert.Core.Helpers;
+
+///
+/// Resolves a Windows shortcut (.lnk) to the file it points at.
+///
+///
+///
+/// A shortcut is a binary file, not a log: handing one to the reader shows its header bytes as log
+/// lines. Callers that accept a path from the user — a drop, the Open dialog, the command line —
+/// pass it through first, so everything downstream only ever sees the target.
+///
+///
+/// The Shell Link binary format (MS-SHLLINK) is parsed here directly instead of through
+/// IShellLink, because this class lives in the Core assembly, which carries no COM or
+/// Windows-only dependency. Every unresolvable case — an unreadable or corrupt shortcut, a target
+/// that no longer exists, a shortcut to a folder — returns the input unchanged, so the caller keeps
+/// the behaviour it had before.
+///
+///
+/// Only the extension is inspected for non-shortcut paths; nothing is read from disk unless the path
+/// actually names an existing .lnk.
+///
+///
+public static class ShellLinkResolver
+{
+ #region Constants
+
+ private const string SHORTCUT_EXTENSION = ".lnk";
+
+ /// Size of ShellLinkHeader, and the value of its first field.
+ private const int SHELL_LINK_HEADER_SIZE = 0x4C;
+
+ /// Offset of LinkFlags: right after the header size and the link CLSID.
+ private const int LINK_FLAGS_OFFSET = 20;
+
+ /// Size of LinkInfo without the optional Unicode offsets.
+ private const int LINK_INFO_MIN_SIZE = 0x1C;
+
+ /// LinkInfoHeaderSize of a LinkInfo that carries the Unicode offsets.
+ private const int LINK_INFO_HEADER_SIZE_UNICODE = 0x24;
+
+ private const int NETWORK_LINK_NET_NAME_OFFSET = 8;
+ private const int NETWORK_LINK_NET_NAME_UNICODE_OFFSET = 20;
+ private const int NETWORK_LINK_MIN_SIZE = 0x1C;
+
+ ///
+ /// Upper bound for chained shortcuts. A shortcut pointing at a shortcut is legitimate; longer
+ /// chains are either pathological or a loop.
+ ///
+ private const int MAX_RESOLVE_DEPTH = 8;
+
+ // LinkFlags
+ private const uint HAS_LINK_TARGET_ID_LIST = 0x00000001;
+ private const uint HAS_LINK_INFO = 0x00000002;
+ private const uint HAS_NAME = 0x00000004;
+ private const uint HAS_RELATIVE_PATH = 0x00000008;
+ private const uint IS_UNICODE = 0x00000080;
+
+ // LinkInfoFlags
+ private const uint VOLUME_ID_AND_LOCAL_BASE_PATH = 0x00000001;
+ private const uint COMMON_NETWORK_RELATIVE_LINK_AND_PATH_SUFFIX = 0x00000002;
+
+ ///
+ /// CLSID of a Shell Link: 00021401-0000-0000-C000-000000000046, in its on-disk byte order.
+ ///
+ private static ReadOnlySpan ShellLinkClsid =>
+ [
+ 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46
+ ];
+
+ #endregion
+
+ #region Fields
+
+ ///
+ /// Encoding for the ANSI strings in the older parts of the format. Resolved lazily and only for
+ /// shortcuts that are not flagged Unicode; the system ANSI code page keeps non-ASCII paths
+ /// intact, and Latin1 is a sufficient fallback where no code pages are available.
+ ///
+ private static readonly Lazy _ansiEncoding = new(ResolveAnsiEncoding);
+
+ #endregion
+
+ #region Public methods
+
+ ///
+ /// Resolves to the file it points at, following chained shortcuts.
+ ///
+ /// A path that may or may not be a shortcut.
+ ///
+ /// The target path when it resolves to an existing file; otherwise
+ /// unchanged.
+ ///
+ public static string Resolve (string fileName)
+ {
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ return fileName;
+ }
+
+ var resolved = fileName;
+
+ for (var depth = 0; depth < MAX_RESOLVE_DEPTH; depth++)
+ {
+ if (!IsShortcut(resolved))
+ {
+ return resolved;
+ }
+
+ var target = TryResolveSingle(resolved);
+ if (target is null)
+ {
+ // Unresolvable shortcut: keep the current path, so the caller behaves as it did
+ // before this class existed.
+ return resolved;
+ }
+
+ resolved = target;
+ }
+
+ return resolved;
+ }
+
+ #endregion
+
+ #region Private methods
+
+ ///
+ /// Whether names an existing file with the shortcut extension. Paths with
+ /// any other extension never touch the disk.
+ ///
+ private static bool IsShortcut (string path)
+ {
+ if (!Path.GetExtension(path).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ try
+ {
+ return File.Exists(path);
+ }
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Reads one shortcut and returns its target, without following a shortcut target that is itself
+ /// a shortcut.
+ ///
+ /// The first candidate that exists on disk, or null.
+ private static string? TryResolveSingle (string shortcutPath)
+ {
+ byte[] bytes;
+
+ try
+ {
+ bytes = File.ReadAllBytes(shortcutPath);
+ }
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException)
+ {
+ return null;
+ }
+
+ var data = bytes.AsSpan();
+ if (!IsShellLink(data))
+ {
+ return null;
+ }
+
+ var flags = BinaryPrimitives.ReadUInt32LittleEndian(data[LINK_FLAGS_OFFSET..]);
+ var isUnicode = (flags & IS_UNICODE) != 0;
+ var position = SHELL_LINK_HEADER_SIZE;
+ var targets = new LinkTargets();
+
+ if ((flags & HAS_LINK_TARGET_ID_LIST) != 0 && !TrySkipIdList(data, ref position))
+ {
+ return null;
+ }
+
+ if ((flags & HAS_LINK_INFO) != 0 && !TryReadLinkInfo(data, ref position, targets))
+ {
+ return null;
+ }
+
+ // StringData follows LinkInfo. Only RELATIVE_PATH is needed, but the entries before it have
+ // variable length and have to be walked over to reach it.
+ if ((flags & HAS_NAME) != 0)
+ {
+ _ = TryReadCountedString(data, ref position, isUnicode, out _);
+ }
+
+ if ((flags & HAS_RELATIVE_PATH) != 0
+ && TryReadCountedString(data, ref position, isUnicode, out var relativePath))
+ {
+ targets.RelativePath = relativePath;
+ }
+
+ return FindExistingTarget(shortcutPath, targets);
+ }
+
+ private static bool IsShellLink (ReadOnlySpan data)
+ {
+ if (data.Length < SHELL_LINK_HEADER_SIZE)
+ {
+ return false;
+ }
+
+ if (BinaryPrimitives.ReadUInt32LittleEndian(data) != SHELL_LINK_HEADER_SIZE)
+ {
+ return false;
+ }
+
+ return data.Slice(4, ShellLinkClsid.Length).SequenceEqual(ShellLinkClsid);
+ }
+
+ ///
+ /// Steps over LinkTargetIDList, a size-prefixed blob of shell item identifiers.
+ ///
+ private static bool TrySkipIdList (ReadOnlySpan data, ref int position)
+ {
+ if (position + 2 > data.Length)
+ {
+ return false;
+ }
+
+ var idListSize = BinaryPrimitives.ReadUInt16LittleEndian(data[position..]);
+ position += 2 + idListSize;
+ return position <= data.Length;
+ }
+
+ ///
+ /// Reads LinkInfo: the local base path, the network path, and the common path suffix.
+ ///
+ private static bool TryReadLinkInfo (ReadOnlySpan data, ref int position, LinkTargets targets)
+ {
+ if (position + LINK_INFO_MIN_SIZE > data.Length)
+ {
+ return false;
+ }
+
+ var linkInfoSize = BinaryPrimitives.ReadUInt32LittleEndian(data[position..]);
+ if (linkInfoSize < LINK_INFO_MIN_SIZE || (long)position + linkInfoSize > data.Length)
+ {
+ return false;
+ }
+
+ var linkInfo = data.Slice(position, (int)linkInfoSize);
+ var linkInfoHeaderSize = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[4..]);
+ var linkInfoFlags = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[8..]);
+ var hasUnicodeOffsets = linkInfoHeaderSize >= LINK_INFO_HEADER_SIZE_UNICODE
+ && linkInfoSize >= LINK_INFO_HEADER_SIZE_UNICODE;
+
+ var localBasePathOffset = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[16..]);
+ var commonPathSuffixOffset = BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[24..]);
+
+ // The Unicode strings win when present: the ANSI ones are a lossy rendering of them.
+ var localBasePathUnicodeOffset = hasUnicodeOffsets
+ ? BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[28..])
+ : 0;
+
+ var commonPathSuffixUnicodeOffset = hasUnicodeOffsets
+ ? BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[32..])
+ : 0;
+
+ if ((linkInfoFlags & VOLUME_ID_AND_LOCAL_BASE_PATH) != 0)
+ {
+ targets.LocalBasePath = ReadStringAt(linkInfo, localBasePathUnicodeOffset, isUnicode: true)
+ ?? ReadStringAt(linkInfo, localBasePathOffset, isUnicode: false);
+ }
+
+ targets.CommonPathSuffix = ReadStringAt(linkInfo, commonPathSuffixUnicodeOffset, isUnicode: true)
+ ?? ReadStringAt(linkInfo, commonPathSuffixOffset, isUnicode: false);
+
+ if ((linkInfoFlags & COMMON_NETWORK_RELATIVE_LINK_AND_PATH_SUFFIX) != 0)
+ {
+ targets.NetworkPath = ReadNetworkPath(linkInfo, BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[20..]));
+ }
+
+ position += (int)linkInfoSize;
+ return true;
+ }
+
+ ///
+ /// Reads the server name out of CommonNetworkRelativeLink. The offsets there are relative
+ /// to that structure, not to LinkInfo.
+ ///
+ private static string? ReadNetworkPath (ReadOnlySpan linkInfo, uint offset)
+ {
+ if (offset == 0 || offset >= (uint)linkInfo.Length)
+ {
+ return null;
+ }
+
+ var start = (int)offset;
+ if (start + NETWORK_LINK_MIN_SIZE > linkInfo.Length)
+ {
+ return null;
+ }
+
+ var networkLink = linkInfo[start..];
+ var netNameOffset = BinaryPrimitives.ReadUInt32LittleEndian(networkLink[NETWORK_LINK_NET_NAME_OFFSET..]);
+ var netNameUnicodeOffset = BinaryPrimitives.ReadUInt32LittleEndian(networkLink[NETWORK_LINK_NET_NAME_UNICODE_OFFSET..]);
+
+ return ReadStringAt(networkLink, netNameUnicodeOffset, isUnicode: true)
+ ?? ReadStringAt(networkLink, netNameOffset, isUnicode: false);
+ }
+
+ ///
+ /// Reads a NUL-terminated string at inside .
+ ///
+ private static string? ReadStringAt (ReadOnlySpan data, uint offset, bool isUnicode)
+ {
+ if (offset == 0 || offset >= (uint)data.Length)
+ {
+ return null;
+ }
+
+ return TryReadNullTerminatedString(data, (int)offset, isUnicode, out var value) && value.Length > 0
+ ? value
+ : null;
+ }
+
+ private static bool TryReadNullTerminatedString (ReadOnlySpan data, int offset, bool isUnicode, out string value)
+ {
+ value = string.Empty;
+
+ if (offset < 0 || offset >= data.Length)
+ {
+ return false;
+ }
+
+ var slice = data[offset..];
+
+ if (isUnicode)
+ {
+ for (var index = 0; index + 1 < slice.Length; index += 2)
+ {
+ if (slice[index] == 0 && slice[index + 1] == 0)
+ {
+ value = Encoding.Unicode.GetString(slice[..index]);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ var terminator = slice.IndexOf((byte)0);
+ if (terminator < 0)
+ {
+ return false;
+ }
+
+ value = _ansiEncoding.Value.GetString(slice[..terminator]);
+ return true;
+ }
+
+ ///
+ /// Reads one StringData entry: a character count, the characters, then a terminator.
+ ///
+ private static bool TryReadCountedString (ReadOnlySpan data, ref int position, bool isUnicode, out string value)
+ {
+ value = string.Empty;
+
+ if (position + 2 > data.Length)
+ {
+ return false;
+ }
+
+ var characterCount = BinaryPrimitives.ReadUInt16LittleEndian(data[position..]);
+ position += 2;
+
+ var byteCount = isUnicode ? characterCount * 2 : characterCount;
+ if (position + byteCount > data.Length)
+ {
+ return false;
+ }
+
+ value = isUnicode
+ ? Encoding.Unicode.GetString(data.Slice(position, byteCount))
+ : _ansiEncoding.Value.GetString(data.Slice(position, byteCount));
+
+ position += byteCount;
+
+ // Some writers omit the terminator on the last entry; clamp instead of failing the parse.
+ position = Math.Min(position + (isUnicode ? 2 : 1), data.Length);
+ return true;
+ }
+
+ ///
+ /// Builds the candidate paths a shortcut may point at and returns the first one that exists.
+ ///
+ ///
+ /// Modern shortcuts usually fill LocalBasePath with the full path, but the field may also
+ /// hold only the directory (with the rest in CommonPathSuffix), and shortcuts that were
+ /// never saved with a volume fall back to a path relative to the shortcut's own directory. All
+ /// of those are tried, most specific first.
+ ///
+ private static string? FindExistingTarget (string shortcutPath, LinkTargets targets)
+ {
+ var shortcutDirectory = Path.GetDirectoryName(shortcutPath);
+ var candidates = new List(5);
+
+ AddCandidate(candidates, targets.LocalBasePath);
+ AddCandidate(candidates, JoinPath(targets.LocalBasePath, targets.CommonPathSuffix));
+ AddCandidate(candidates, JoinPath(targets.NetworkPath, targets.CommonPathSuffix));
+ AddCandidate(candidates, targets.NetworkPath);
+ AddCandidate(candidates, targets.RelativePath);
+
+ foreach (var candidate in candidates)
+ {
+ var path = candidate;
+
+ if (!Path.IsPathRooted(path))
+ {
+ if (shortcutDirectory is null)
+ {
+ continue;
+ }
+
+ path = Path.Join(shortcutDirectory, path);
+ }
+
+ try
+ {
+ path = Path.GetFullPath(path);
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or NotSupportedException
+ or PathTooLongException
+ or System.Security.SecurityException)
+ {
+ continue;
+ }
+
+ if (File.Exists(path))
+ {
+ return path;
+ }
+ }
+
+ return null;
+ }
+
+ private static void AddCandidate (List candidates, string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ string expanded;
+
+ try
+ {
+ expanded = Environment.ExpandEnvironmentVariables(path);
+ }
+ catch (Exception ex) when (ex is ArgumentException or System.Security.SecurityException)
+ {
+ return;
+ }
+
+ if (expanded.Length > 0 && !candidates.Contains(expanded, StringComparer.OrdinalIgnoreCase))
+ {
+ candidates.Add(expanded);
+ }
+ }
+
+ ///
+ /// Joins a base path and a suffix, tolerating a separator on either side.
+ ///
+ private static string? JoinPath (string? basePath, string? suffix)
+ {
+ if (string.IsNullOrEmpty(basePath) || string.IsNullOrEmpty(suffix))
+ {
+ return null;
+ }
+
+ return Path.Join(
+ basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
+ suffix.TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ }
+
+ private static Encoding ResolveAnsiEncoding ()
+ {
+ try
+ {
+ // InstalledUICulture, not CurrentCulture: the UI language the user picked is applied to
+ // CurrentCulture at startup, which would otherwise report the wrong ANSI code page.
+ var codePage = CultureInfo.InstalledUICulture.TextInfo.ANSICodePage;
+ if (codePage > 0)
+ {
+ return EncodingRegistry.GetEncoding(codePage);
+ }
+ }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
+ {
+ // No code pages are available; Latin1 still decodes the ASCII paths this branch sees.
+ }
+
+ return Encoding.Latin1;
+ }
+
+ #endregion
+
+ #region Data types
+
+ ///
+ /// The path fields a shortcut may carry, in the order they are parsed.
+ ///
+ private sealed class LinkTargets
+ {
+ public string? LocalBasePath { get; set; }
+
+ public string? CommonPathSuffix { get; set; }
+
+ public string? NetworkPath { get; set; }
+
+ public string? RelativePath { get; set; }
+ }
+
+ #endregion
+}
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 0c72551c6..00a6d5247 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -68,7 +68,11 @@ internal sealed class FileOperationService (
public LogWindow AddFileTab (FileTabRequest request)
{
- var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry);
+ // A shortcut names a file the user did not open: resolve it before anything else, so the tab,
+ // the reader and the Session File all deal with the target. The request keeps the original
+ // path, so what the user picked is still what is shown and remembered.
+ var requestedFile = ShellLinkResolver.Resolve(request.FileName);
+ var logFileName = PersisterHelpers.FindFilenameForSettings(requestedFile, _pluginRegistry);
var normalizedPath = Path.GetFullPath(logFileName);
var existingWindow = FindWindowForFile(normalizedPath);
@@ -102,9 +106,9 @@ public LogWindow AddFileTab (FileTabRequest request)
AddToFileHistory(request.FileName);
}
- if (request.FileName.EndsWith(".lxp", StringComparison.Ordinal))
+ if (requestedFile.EndsWith(".lxp", StringComparison.Ordinal))
{
- logWindow.ForcedPersistenceFileName = request.FileName;
+ logWindow.ForcedPersistenceFileName = requestedFile;
}
// Register before starting the asynchronous load, including very small files.
@@ -204,14 +208,18 @@ public LogWindow AddTempFileTab (string fileName, string title)
return null;
}
+ // Shortcuts are resolved for the same reason as in AddFileTab: only the reader needs the
+ // targets, while the request keeps the path the user dropped.
+ var resolvedFileNames = fileNames.Select(ShellLinkResolver.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.
- if (fileNames.Any(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)))
+ if (resolvedFileNames.Any(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)))
{
- AddFileTabs(fileNames.Where(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray());
+ AddFileTabs(resolvedFileNames.Where(f => f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray());
- var logs = fileNames.Where(f => !f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray();
+ var logs = resolvedFileNames.Where(f => !f.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase)).ToArray();
return logs.Length > 0 ? AddMultiFileTab(logs) : null;
}
@@ -235,9 +243,9 @@ public LogWindow AddTempFileTab (string fileName, string title)
{
LogWindow = logWindow,
Request = request,
- ResolvedFileName = fileNames[^1],
+ ResolvedFileName = resolvedFileNames[^1],
EncodingOptions = encodingOptions,
- MultiFileNames = fileNames
+ MultiFileNames = resolvedFileNames
});
return logWindow;
From 3c2aded3f9c3f9a0aa59592cbc6c20bc517f2df8 Mon Sep 17 00:00:00 2001
From: mfkeeq <546357529@qq.com>
Date: Fri, 23 Oct 2026 00:19:57 +0800
Subject: [PATCH 2/2] Resolve shortcuts without COM and refuse network targets
Shortcut resolution parses MS-SHLLINK directly instead of going through
IShellLink, which keeps it in the Core assembly and makes the byte layout
testable. Parsing is split from the file-system lookup, so tests can feed
crafted shortcuts in without touching a disk.
Only local targets are followed. UNC paths are skipped because opening one
makes Windows authenticate to that host with the user's NetNTLM hash, and
device paths reach devices and pipes that can block. Shortcuts above 1 MB are
rejected as malformed rather than read whole.
FileOperationService passes the resolved target through FileTabRequest, so the
reader and the session file use the target while the title, history and recent
files keep the path the user picked.
---
.../Classes/Persister/PersisterHelpers.cs | 11 +-
.../Helpers/ShellLinkResolver.cs | 357 ++++++++++--------
.../SessionFileValidatorTests.cs | 39 ++
.../Helpers/ShellLinkFixture.cs | 151 ++++++++
.../Helpers/ShellLinkResolverTests.cs | 248 ++++++++++++
.../Services/FileOperationServiceTests.cs | 77 ++++
.../Dialogs/LogTabWindow/LogTabWindow.cs | 10 +-
.../FileOperationService.cs | 29 +-
.../FileOperationService/FileTabRequest.cs | 9 +-
9 files changed, 743 insertions(+), 188 deletions(-)
create mode 100644 src/LogExpert.Tests/Helpers/ShellLinkFixture.cs
create mode 100644 src/LogExpert.Tests/Helpers/ShellLinkResolverTests.cs
diff --git a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
index fd1de128e..965daf471 100644
--- a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
+++ b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs
@@ -1,6 +1,5 @@
using System.Collections.ObjectModel;
-using LogExpert.Core.Helpers;
using LogExpert.Core.Interfaces;
namespace LogExpert.Core.Classes.Persister;
@@ -11,9 +10,8 @@ public static class PersisterHelpers
private const string LOCAL_FILE_SYSTEM_NAME = "LocalFileSystem";
///
- /// Resolves a shortcut to the file it points at, then checks if that file is a settings file
- /// (.lxp). If so, the contained logfile name is returned. If not, the given file name is
- /// returned unchanged.
+ /// Returns the log file named by a settings file (.lxp), or unchanged
+ /// when it is not one.
///
/// The file name to resolve
/// Plugin registry for file system resolution (optional)
@@ -22,11 +20,6 @@ public static string FindFilenameForSettings (string fileName, IPluginRegistry p
{
ArgumentException.ThrowIfNullOrWhiteSpace(fileName, nameof(fileName));
- // A shortcut is not a log file: resolve it first, so the rest of this method — the .lxp
- // lookup included — sees the file the user actually meant. Anything unresolvable comes back
- // unchanged and keeps the previous behaviour.
- fileName = ShellLinkResolver.Resolve(fileName);
-
if (fileName.EndsWith(".lxp", StringComparison.OrdinalIgnoreCase))
{
var persistenceData = Persister.Load(fileName);
diff --git a/src/LogExpert.Core/Helpers/ShellLinkResolver.cs b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs
index 390764e36..ff4f74d59 100644
--- a/src/LogExpert.Core/Helpers/ShellLinkResolver.cs
+++ b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs
@@ -2,28 +2,17 @@
using System.Globalization;
using System.Text;
+using NLog;
+
namespace LogExpert.Core.Helpers;
///
-/// Resolves a Windows shortcut (.lnk) to the file it points at.
+/// Resolves a Windows shortcut (.lnk) to the local file it points at.
///
///
-///
-/// A shortcut is a binary file, not a log: handing one to the reader shows its header bytes as log
-/// lines. Callers that accept a path from the user — a drop, the Open dialog, the command line —
-/// pass it through first, so everything downstream only ever sees the target.
-///
-///
-/// The Shell Link binary format (MS-SHLLINK) is parsed here directly instead of through
-/// IShellLink, because this class lives in the Core assembly, which carries no COM or
-/// Windows-only dependency. Every unresolvable case — an unreadable or corrupt shortcut, a target
-/// that no longer exists, a shortcut to a folder — returns the input unchanged, so the caller keeps
-/// the behaviour it had before.
-///
-///
-/// Only the extension is inspected for non-shortcut paths; nothing is read from disk unless the path
-/// actually names an existing .lnk.
-///
+/// The MS-SHLLINK format is parsed directly rather than through IShellLink, because this
+/// lives in the Core assembly, which carries no COM. Only local targets are followed: UNC and
+/// device paths are skipped, and anything unresolvable is returned unchanged.
///
public static class ShellLinkResolver
{
@@ -43,9 +32,11 @@ public static class ShellLinkResolver
/// LinkInfoHeaderSize of a LinkInfo that carries the Unicode offsets.
private const int LINK_INFO_HEADER_SIZE_UNICODE = 0x24;
- private const int NETWORK_LINK_NET_NAME_OFFSET = 8;
- private const int NETWORK_LINK_NET_NAME_UNICODE_OFFSET = 20;
- private const int NETWORK_LINK_MIN_SIZE = 0x1C;
+ ///
+ /// Real shortcuts are a few KB. Anything larger is not one, and reading it whole would let a
+ /// dropped file exhaust memory.
+ ///
+ private const int MAX_SHORTCUT_SIZE = 1024 * 1024;
///
/// Upper bound for chained shortcuts. A shortcut pointing at a shortcut is legitimate; longer
@@ -62,7 +53,6 @@ public static class ShellLinkResolver
// LinkInfoFlags
private const uint VOLUME_ID_AND_LOCAL_BASE_PATH = 0x00000001;
- private const uint COMMON_NETWORK_RELATIVE_LINK_AND_PATH_SUFFIX = 0x00000002;
///
/// CLSID of a Shell Link: 00021401-0000-0000-C000-000000000046, in its on-disk byte order.
@@ -77,10 +67,11 @@ public static class ShellLinkResolver
#region Fields
+ private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
+
///
- /// Encoding for the ANSI strings in the older parts of the format. Resolved lazily and only for
- /// shortcuts that are not flagged Unicode; the system ANSI code page keeps non-ASCII paths
- /// intact, and Latin1 is a sufficient fallback where no code pages are available.
+ /// Encoding of the ANSI strings. Resolved lazily and only for shortcuts that are not flagged
+ /// Unicode; Latin1 is a sufficient fallback where no code pages are available.
///
private static readonly Lazy _ansiEncoding = new(ResolveAnsiEncoding);
@@ -93,8 +84,8 @@ public static class ShellLinkResolver
///
/// A path that may or may not be a shortcut.
///
- /// The target path when it resolves to an existing file; otherwise
- /// unchanged.
+ /// The target path when the whole chain resolves to an existing local file; otherwise
+ /// unchanged.
///
public static string Resolve (string fileName)
{
@@ -103,27 +94,134 @@ public static string Resolve (string fileName)
return fileName;
}
- var resolved = fileName;
+ var current = fileName;
for (var depth = 0; depth < MAX_RESOLVE_DEPTH; depth++)
{
- if (!IsShortcut(resolved))
+ if (!IsShortcutFile(current))
{
- return resolved;
+ return current;
}
- var target = TryResolveSingle(resolved);
+ var target = TryResolveSingle(current);
if (target is null)
{
- // Unresolvable shortcut: keep the current path, so the caller behaves as it did
- // before this class existed.
- return resolved;
+ // Keep what the caller passed in, not a half-followed chain, so a broken second link
+ // behaves exactly like a broken first one.
+ _logger.Debug("Shortcut {Shortcut} is unresolvable, keeping {Original}", current, fileName);
+ return fileName;
+ }
+
+ current = target;
+ }
+
+ _logger.Warn("Shortcut chain starting at {Shortcut} exceeds {Depth} links, keeping the original path", fileName, MAX_RESOLVE_DEPTH);
+ return fileName;
+ }
+
+ #endregion
+
+ #region Internal parsing core
+
+ ///
+ /// Parses the path fields out of shortcut bytes. Separate from and free of
+ /// I/O so tests can feed crafted shortcuts in.
+ ///
+ internal static bool TryParseTargets (ReadOnlySpan data, out ShortcutTargets targets)
+ {
+ targets = new ShortcutTargets();
+
+ if (!IsShellLink(data))
+ {
+ return false;
+ }
+
+ var flags = BinaryPrimitives.ReadUInt32LittleEndian(data[LINK_FLAGS_OFFSET..]);
+ var isUnicode = (flags & IS_UNICODE) != 0;
+ var position = SHELL_LINK_HEADER_SIZE;
+
+ if ((flags & HAS_LINK_TARGET_ID_LIST) != 0 && !TrySkipIdList(data, ref position))
+ {
+ return false;
+ }
+
+ if ((flags & HAS_LINK_INFO) != 0 && !TryReadLinkInfo(data, ref position, targets))
+ {
+ return false;
+ }
+
+ // StringData follows LinkInfo, one entry per flag, in a fixed order. Only RELATIVE_PATH is
+ // needed, but NAME_STRING has to be stepped over to reach it.
+ if ((flags & HAS_NAME) != 0 && !TryReadStringData(data, ref position, isUnicode, out _))
+ {
+ return false;
+ }
+
+ if ((flags & HAS_RELATIVE_PATH) != 0)
+ {
+ if (!TryReadStringData(data, ref position, isUnicode, out var relativePath))
+ {
+ return false;
+ }
+
+ targets.RelativePath = relativePath;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Turns the parsed fields into absolute paths, most specific first, dropping anything that is
+ /// not on this machine. No file is touched.
+ ///
+ internal static IReadOnlyList BuildCandidatePaths (string shortcutPath, ShortcutTargets targets)
+ {
+ var shortcutDirectory = Path.GetDirectoryName(shortcutPath);
+ var candidates = new List(3);
+
+ AddCandidate(candidates, targets.LocalBasePath);
+ AddCandidate(candidates, JoinPath(targets.LocalBasePath, targets.CommonPathSuffix));
+ AddCandidate(candidates, targets.RelativePath);
+
+ var result = new List(candidates.Count);
+
+ foreach (var candidate in candidates)
+ {
+ var path = candidate;
+
+ if (!Path.IsPathRooted(path))
+ {
+ if (shortcutDirectory is null)
+ {
+ continue;
+ }
+
+ path = Path.Join(shortcutDirectory, path);
+ }
+
+ try
+ {
+ path = Path.GetFullPath(path);
+ }
+ catch (Exception ex) when (ex is ArgumentException
+ or NotSupportedException
+ or PathTooLongException
+ or System.Security.SecurityException)
+ {
+ continue;
}
- resolved = target;
+ if (IsLocalFilePath(path))
+ {
+ result.Add(path);
+ }
+ else
+ {
+ _logger.Debug("Not following non-local shortcut target {Target}", path);
+ }
}
- return resolved;
+ return result;
}
#endregion
@@ -134,7 +232,7 @@ public static string Resolve (string fileName)
/// Whether names an existing file with the shortcut extension. Paths with
/// any other extension never touch the disk.
///
- private static bool IsShortcut (string path)
+ private static bool IsShortcutFile (string path)
{
if (!Path.GetExtension(path).Equals(SHORTCUT_EXTENSION, StringComparison.OrdinalIgnoreCase))
{
@@ -156,62 +254,52 @@ or NotSupportedException
}
///
- /// Reads one shortcut and returns its target, without following a shortcut target that is itself
- /// a shortcut.
+ /// Reads one shortcut and returns its target, without following a target that is itself a
+ /// shortcut.
///
/// The first candidate that exists on disk, or null.
private static string? TryResolveSingle (string shortcutPath)
{
- byte[] bytes;
-
- try
- {
- bytes = File.ReadAllBytes(shortcutPath);
- }
- catch (Exception ex) when (ex is IOException
- or UnauthorizedAccessException
- or ArgumentException
- or NotSupportedException
- or System.Security.SecurityException)
+ var data = TryReadShortcut(shortcutPath);
+ if (data is null)
{
return null;
}
- var data = bytes.AsSpan();
- if (!IsShellLink(data))
+ if (!TryParseTargets(data, out var targets))
{
+ _logger.Warn("Ignoring {Shortcut}: not a valid shortcut", shortcutPath);
return null;
}
- var flags = BinaryPrimitives.ReadUInt32LittleEndian(data[LINK_FLAGS_OFFSET..]);
- var isUnicode = (flags & IS_UNICODE) != 0;
- var position = SHELL_LINK_HEADER_SIZE;
- var targets = new LinkTargets();
+ return FindExistingTarget(shortcutPath, targets);
+ }
- if ((flags & HAS_LINK_TARGET_ID_LIST) != 0 && !TrySkipIdList(data, ref position))
+ private static byte[]? TryReadShortcut (string shortcutPath)
+ {
+ try
{
- return null;
- }
+ using var stream = new FileStream(shortcutPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
- if ((flags & HAS_LINK_INFO) != 0 && !TryReadLinkInfo(data, ref position, targets))
- {
- return null;
- }
+ if (stream.Length > MAX_SHORTCUT_SIZE)
+ {
+ _logger.Warn("Ignoring {Shortcut}: {Size} bytes is above the {Limit} byte shortcut limit", shortcutPath, stream.Length, MAX_SHORTCUT_SIZE);
+ return null;
+ }
- // StringData follows LinkInfo. Only RELATIVE_PATH is needed, but the entries before it have
- // variable length and have to be walked over to reach it.
- if ((flags & HAS_NAME) != 0)
- {
- _ = TryReadCountedString(data, ref position, isUnicode, out _);
+ var bytes = new byte[stream.Length];
+ stream.ReadExactly(bytes);
+ return bytes;
}
-
- if ((flags & HAS_RELATIVE_PATH) != 0
- && TryReadCountedString(data, ref position, isUnicode, out var relativePath))
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
+ or NotSupportedException
+ or System.Security.SecurityException)
{
- targets.RelativePath = relativePath;
+ _logger.Debug(ex, "Could not read shortcut {Shortcut}", shortcutPath);
+ return null;
}
-
- return FindExistingTarget(shortcutPath, targets);
}
private static bool IsShellLink (ReadOnlySpan data)
@@ -245,9 +333,10 @@ private static bool TrySkipIdList (ReadOnlySpan data, ref int position)
}
///
- /// Reads LinkInfo: the local base path, the network path, and the common path suffix.
+ /// Reads the local base path and the common path suffix out of LinkInfo. The network part
+ /// is deliberately not read: its targets are never followed.
///
- private static bool TryReadLinkInfo (ReadOnlySpan data, ref int position, LinkTargets targets)
+ private static bool TryReadLinkInfo (ReadOnlySpan data, ref int position, ShortcutTargets targets)
{
if (position + LINK_INFO_MIN_SIZE > data.Length)
{
@@ -287,40 +376,10 @@ private static bool TryReadLinkInfo (ReadOnlySpan data, ref int position,
targets.CommonPathSuffix = ReadStringAt(linkInfo, commonPathSuffixUnicodeOffset, isUnicode: true)
?? ReadStringAt(linkInfo, commonPathSuffixOffset, isUnicode: false);
- if ((linkInfoFlags & COMMON_NETWORK_RELATIVE_LINK_AND_PATH_SUFFIX) != 0)
- {
- targets.NetworkPath = ReadNetworkPath(linkInfo, BinaryPrimitives.ReadUInt32LittleEndian(linkInfo[20..]));
- }
-
position += (int)linkInfoSize;
return true;
}
- ///
- /// Reads the server name out of CommonNetworkRelativeLink. The offsets there are relative
- /// to that structure, not to LinkInfo.
- ///
- private static string? ReadNetworkPath (ReadOnlySpan linkInfo, uint offset)
- {
- if (offset == 0 || offset >= (uint)linkInfo.Length)
- {
- return null;
- }
-
- var start = (int)offset;
- if (start + NETWORK_LINK_MIN_SIZE > linkInfo.Length)
- {
- return null;
- }
-
- var networkLink = linkInfo[start..];
- var netNameOffset = BinaryPrimitives.ReadUInt32LittleEndian(networkLink[NETWORK_LINK_NET_NAME_OFFSET..]);
- var netNameUnicodeOffset = BinaryPrimitives.ReadUInt32LittleEndian(networkLink[NETWORK_LINK_NET_NAME_UNICODE_OFFSET..]);
-
- return ReadStringAt(networkLink, netNameUnicodeOffset, isUnicode: true)
- ?? ReadStringAt(networkLink, netNameOffset, isUnicode: false);
- }
-
///
/// Reads a NUL-terminated string at inside .
///
@@ -372,9 +431,10 @@ private static bool TryReadNullTerminatedString (ReadOnlySpan data, int of
}
///
- /// Reads one StringData entry: a character count, the characters, then a terminator.
+ /// Reads one StringData entry: a character count followed by that many characters, with
+ /// no terminator (MS-SHLLINK 2.4).
///
- private static bool TryReadCountedString (ReadOnlySpan data, ref int position, bool isUnicode, out string value)
+ private static bool TryReadStringData (ReadOnlySpan data, ref int position, bool isUnicode, out string value)
{
value = string.Empty;
@@ -397,67 +457,46 @@ private static bool TryReadCountedString (ReadOnlySpan data, ref int posit
: _ansiEncoding.Value.GetString(data.Slice(position, byteCount));
position += byteCount;
-
- // Some writers omit the terminator on the last entry; clamp instead of failing the parse.
- position = Math.Min(position + (isUnicode ? 2 : 1), data.Length);
return true;
}
///
- /// Builds the candidate paths a shortcut may point at and returns the first one that exists.
+ /// Returns the first candidate path that exists as a file.
///
- ///
- /// Modern shortcuts usually fill LocalBasePath with the full path, but the field may also
- /// hold only the directory (with the rest in CommonPathSuffix), and shortcuts that were
- /// never saved with a volume fall back to a path relative to the shortcut's own directory. All
- /// of those are tried, most specific first.
- ///
- private static string? FindExistingTarget (string shortcutPath, LinkTargets targets)
+ private static string? FindExistingTarget (string shortcutPath, ShortcutTargets targets)
{
- var shortcutDirectory = Path.GetDirectoryName(shortcutPath);
- var candidates = new List(5);
-
- AddCandidate(candidates, targets.LocalBasePath);
- AddCandidate(candidates, JoinPath(targets.LocalBasePath, targets.CommonPathSuffix));
- AddCandidate(candidates, JoinPath(targets.NetworkPath, targets.CommonPathSuffix));
- AddCandidate(candidates, targets.NetworkPath);
- AddCandidate(candidates, targets.RelativePath);
-
- foreach (var candidate in candidates)
+ foreach (var candidate in BuildCandidatePaths(shortcutPath, targets))
{
- var path = candidate;
-
- if (!Path.IsPathRooted(path))
+ try
{
- if (shortcutDirectory is null)
+ if (File.Exists(candidate))
{
- continue;
+ return candidate;
}
-
- path = Path.Join(shortcutDirectory, path);
}
-
- try
- {
- path = Path.GetFullPath(path);
- }
- catch (Exception ex) when (ex is ArgumentException
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or ArgumentException
or NotSupportedException
- or PathTooLongException
or System.Security.SecurityException)
{
- continue;
- }
-
- if (File.Exists(path))
- {
- return path;
+ _logger.Debug(ex, "Could not look up shortcut target {Target}", candidate);
}
}
return null;
}
+ ///
+ /// Whether stays on this machine. \\host\share would make Windows
+ /// authenticate to that host, handing it the user's NetNTLM hash, and \\.\ or
+ /// \\?\ reach devices and pipes that can block.
+ ///
+ private static bool IsLocalFilePath (string path)
+ {
+ return !path.StartsWith(@"\\", StringComparison.Ordinal);
+ }
+
private static void AddCandidate (List candidates, string? path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -465,20 +504,9 @@ private static void AddCandidate (List candidates, string? path)
return;
}
- string expanded;
-
- try
+ if (!candidates.Contains(path, StringComparer.OrdinalIgnoreCase))
{
- expanded = Environment.ExpandEnvironmentVariables(path);
- }
- catch (Exception ex) when (ex is ArgumentException or System.Security.SecurityException)
- {
- return;
- }
-
- if (expanded.Length > 0 && !candidates.Contains(expanded, StringComparer.OrdinalIgnoreCase))
- {
- candidates.Add(expanded);
+ candidates.Add(path);
}
}
@@ -522,16 +550,15 @@ private static Encoding ResolveAnsiEncoding ()
#region Data types
///
- /// The path fields a shortcut may carry, in the order they are parsed.
+ /// The path fields a shortcut carries, as parsed. A plain holder: resolving them against the
+ /// file system is 's job.
///
- private sealed class LinkTargets
+ internal sealed class ShortcutTargets
{
public string? LocalBasePath { get; set; }
public string? CommonPathSuffix { get; set; }
- public string? NetworkPath { get; set; }
-
public string? RelativePath { get; set; }
}
diff --git a/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs b/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs
index 4d84402c8..8d18f1ccc 100644
--- a/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs
+++ b/src/LogExpert.Persister.Tests/SessionFileValidatorTests.cs
@@ -1,6 +1,9 @@
+using System.Buffers.Binary;
using System.Globalization;
+using System.Text;
using LogExpert.Core.Classes.Persister;
+using LogExpert.Core.Helpers;
namespace LogExpert.Persister.Tests;
@@ -205,6 +208,42 @@ public void PersisterHelpers_FindFilenameForSettings_MixedLxpAndLog_ResolvesBoth
Assert.That(result[1], Does.EndWith("referenced.log"), ".lxp should resolve to referenced log");
}
+ [Test]
+ public void PersisterHelpers_FindFilenameForSettings_ShortcutToLxp_ResolvesToLog ()
+ {
+ // Arrange - a shortcut to the settings file, which in turn names the log
+ CreateTestLogFiles("actual.log");
+ CreatePersistenceFile("settings.lxp", "actual.log");
+ var shortcutPath = Path.Join(_testDirectory, "settings.lnk");
+ File.WriteAllBytes(shortcutPath, BuildRelativePathShortcut("settings.lxp"));
+
+ // Act - the shortcut is resolved at the entry point, then the settings file is followed
+ var shortcutTarget = ShellLinkResolver.Resolve(shortcutPath);
+ var result = PersisterHelpers.FindFilenameForSettings(shortcutTarget, PluginRegistry.PluginRegistry.Instance);
+
+ // Assert
+ Assert.That(shortcutTarget, Does.EndWith("settings.lxp"), "Shortcut should resolve to the settings file");
+ Assert.That(result, Does.EndWith("actual.log"), "Settings file should then resolve to the log");
+ }
+
+ ///
+ /// Builds a shortcut whose only target is RELATIVE_PATH. The other projects build theirs with a
+ /// fixture; this one needs a handful of bytes, so it writes them here.
+ ///
+ private static byte[] BuildRelativePathShortcut (string relativePath)
+ {
+ var bytes = new byte[0x4C + 2 + (relativePath.Length * 2)];
+
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x4C);
+ new byte[] { 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 }
+ .CopyTo(bytes, 4);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 0x00000088);
+ BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(0x4C), (ushort)relativePath.Length);
+ _ = Encoding.Unicode.GetBytes(relativePath, bytes.AsSpan(0x4E));
+
+ return bytes;
+ }
+
[Test]
public void PersisterHelpers_FindFilenameForSettings_CorruptedLxp_ReturnsLxpPath ()
{
diff --git a/src/LogExpert.Tests/Helpers/ShellLinkFixture.cs b/src/LogExpert.Tests/Helpers/ShellLinkFixture.cs
new file mode 100644
index 000000000..312be3826
--- /dev/null
+++ b/src/LogExpert.Tests/Helpers/ShellLinkFixture.cs
@@ -0,0 +1,151 @@
+using System.Text;
+
+namespace LogExpert.Tests.Helpers;
+
+///
+/// Builds shortcut byte arrays for tests. Only the fields the resolver reads are written: header,
+/// an optional LinkInfo with a local base path and common suffix, and the StringData entries for
+/// NAME_STRING and RELATIVE_PATH.
+///
+internal static class ShellLinkFixture
+{
+ private const int HEADER_SIZE = 0x4C;
+
+ private const uint HAS_LINK_INFO = 0x00000002;
+ private const uint HAS_NAME = 0x00000004;
+ private const uint HAS_RELATIVE_PATH = 0x00000008;
+ private const uint IS_UNICODE = 0x00000080;
+
+ private static ReadOnlySpan Clsid =>
+ [
+ 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46
+ ];
+
+ ///
+ /// Builds a shortcut. selects the Unicode encoding for both LinkInfo
+ /// and StringData, as the IsUnicode flag does in the real format.
+ ///
+ internal static byte[] Build (
+ string? localBasePath = null,
+ string? commonPathSuffix = null,
+ string? name = null,
+ string? relativePath = null,
+ bool unicode = true,
+ bool corruptSignature = false)
+ {
+ var flags = unicode ? IS_UNICODE : 0u;
+
+ if (name is not null)
+ {
+ flags |= HAS_NAME;
+ }
+
+ if (relativePath is not null)
+ {
+ flags |= HAS_RELATIVE_PATH;
+ }
+
+ byte[]? linkInfo = null;
+ if (localBasePath is not null || commonPathSuffix is not null)
+ {
+ flags |= HAS_LINK_INFO;
+ linkInfo = BuildLinkInfo(localBasePath ?? string.Empty, commonPathSuffix ?? string.Empty, unicode);
+ }
+
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter(stream, Encoding.Unicode, true);
+
+ writer.Write((uint)HEADER_SIZE);
+ writer.Write(corruptSignature ? new byte[Clsid.Length] : Clsid.ToArray());
+ writer.Write(flags);
+ writer.Write(new byte[HEADER_SIZE - 24]);
+
+ if (linkInfo is not null)
+ {
+ writer.Write(linkInfo);
+ }
+
+ if (name is not null)
+ {
+ WriteStringData(writer, name, unicode);
+ }
+
+ if (relativePath is not null)
+ {
+ WriteStringData(writer, relativePath, unicode);
+ }
+
+ writer.Flush();
+ return stream.ToArray();
+ }
+
+ private static byte[] BuildLinkInfo (string localBasePath, string commonPathSuffix, bool unicode)
+ {
+ // The ANSI copy is always present when VOLUME_ID_AND_LOCAL_BASE_PATH is set; the Unicode
+ // one only when the header is large enough to carry its offsets.
+ var headerSize = unicode ? 0x24 : 0x1C;
+ var ansiLocal = Encoding.Latin1.GetBytes(localBasePath);
+ var ansiSuffix = Encoding.Latin1.GetBytes(commonPathSuffix);
+
+ var ansiLocalOffset = headerSize;
+ var ansiSuffixOffset = ansiLocalOffset + ansiLocal.Length + 1;
+ var unicodeLocalOffset = ansiSuffixOffset + ansiSuffix.Length + 1;
+ var unicodeSuffixOffset = unicodeLocalOffset + ((localBasePath.Length + 1) * 2);
+
+ var size = unicode
+ ? unicodeSuffixOffset + ((commonPathSuffix.Length + 1) * 2)
+ : ansiSuffixOffset + ansiSuffix.Length + 1;
+
+ using var stream = new MemoryStream();
+ using var writer = new BinaryWriter(stream, Encoding.Unicode, true);
+
+ writer.Write((uint)size);
+ writer.Write((uint)headerSize);
+ writer.Write(1u); // VOLUME_ID_AND_LOCAL_BASE_PATH
+ writer.Write(0u); // VolumeIDOffset
+ writer.Write((uint)ansiLocalOffset);
+ writer.Write(0u); // CommonNetworkRelativeLinkOffset
+ writer.Write((uint)ansiSuffixOffset);
+
+ if (unicode)
+ {
+ writer.Write((uint)unicodeLocalOffset);
+ writer.Write((uint)unicodeSuffixOffset);
+ }
+
+ writer.Write(ansiLocal);
+ writer.Write((byte)0);
+ writer.Write(ansiSuffix);
+ writer.Write((byte)0);
+
+ if (unicode)
+ {
+ writer.Write(Encoding.Unicode.GetBytes(localBasePath));
+ writer.Write((ushort)0);
+ writer.Write(Encoding.Unicode.GetBytes(commonPathSuffix));
+ writer.Write((ushort)0);
+ }
+
+ writer.Flush();
+ return stream.ToArray();
+ }
+
+ ///
+ /// Writes one StringData entry: a character count, then the characters. MS-SHLLINK 2.4 puts no
+ /// NULL terminator after them.
+ ///
+ private static void WriteStringData (BinaryWriter writer, string value, bool unicode)
+ {
+ writer.Write((ushort)value.Length);
+
+ if (unicode)
+ {
+ writer.Write(Encoding.Unicode.GetBytes(value));
+ }
+ else
+ {
+ writer.Write(Encoding.Latin1.GetBytes(value));
+ }
+ }
+}
diff --git a/src/LogExpert.Tests/Helpers/ShellLinkResolverTests.cs b/src/LogExpert.Tests/Helpers/ShellLinkResolverTests.cs
new file mode 100644
index 000000000..1f5160f6b
--- /dev/null
+++ b/src/LogExpert.Tests/Helpers/ShellLinkResolverTests.cs
@@ -0,0 +1,248 @@
+using LogExpert.Core.Helpers;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.Helpers;
+
+///
+/// Tests for . The parsing tests feed crafted byte arrays in, so they
+/// need no real shortcut; the resolution tests use a temporary directory.
+///
+[TestFixture]
+public class ShellLinkResolverTests
+{
+ private string _directory;
+
+ [SetUp]
+ public void Setup ()
+ {
+ _directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShellLink", Guid.NewGuid().ToString());
+ _ = Directory.CreateDirectory(_directory);
+ }
+
+ [TearDown]
+ public void TearDown ()
+ {
+ if (!Directory.Exists(_directory))
+ {
+ return;
+ }
+
+ try
+ {
+ Directory.Delete(_directory, true);
+ }
+ catch (IOException)
+ {
+ // A file still held open by another process; the temp directory gets cleaned up anyway.
+ }
+ }
+
+ #region Helper methods
+
+ private string CreateLog (string relativePath)
+ {
+ var path = Path.Join(_directory, relativePath);
+ _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, "log line");
+ return path;
+ }
+
+ private string CreateShortcut (string fileName, byte[] bytes)
+ {
+ var path = Path.Join(_directory, fileName);
+ File.WriteAllBytes(path, bytes);
+ return path;
+ }
+
+ #endregion
+
+ #region Parsing
+
+ [Test]
+ public void TryParseTargets_UnicodeLinkInfo_ReadsLocalBasePath ()
+ {
+ var data = ShellLinkFixture.Build(localBasePath: @"C:\logs\app.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(targets.LocalBasePath, Is.EqualTo(@"C:\logs\app.log"));
+ }
+
+ [Test]
+ public void TryParseTargets_AnsiLinkInfo_ReadsLocalBasePath ()
+ {
+ var data = ShellLinkFixture.Build(localBasePath: @"C:\logs\app.log", unicode: false);
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(targets.LocalBasePath, Is.EqualTo(@"C:\logs\app.log"));
+ }
+
+ [Test]
+ public void TryParseTargets_NameBeforeRelativePath_ReadsRelativePath ()
+ {
+ // StringData carries no NULL terminator (MS-SHLLINK 2.4). Skipping one used to land four
+ // bytes into RELATIVE_PATH, which the old resolution order then hid.
+ var data = ShellLinkFixture.Build(name: "a comment", relativePath: @"sub\target.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(targets.RelativePath, Is.EqualTo(@"sub\target.log"));
+ }
+
+ [Test]
+ public void TryParseTargets_TruncatedHeader_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: "app.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data.AsSpan(0, 0x40), out _), Is.False);
+ }
+
+ [Test]
+ public void TryParseTargets_TruncatedLinkInfo_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(localBasePath: @"C:\logs\app.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data.AsSpan(0, 0x4C + 10), out _), Is.False);
+ }
+
+ [Test]
+ public void TryParseTargets_TruncatedStringData_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: "a-long-relative-path.log");
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data.AsSpan(0, data.Length - 4), out _), Is.False);
+ }
+
+ [Test]
+ public void TryParseTargets_CorruptSignature_ReturnsFalse ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: "app.log", corruptSignature: true);
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out _), Is.False);
+ }
+
+ #endregion
+
+ #region Candidate paths
+
+ [Test]
+ public void BuildCandidatePaths_LocalTarget_ReturnsAbsolutePath ()
+ {
+ var data = ShellLinkFixture.Build(relativePath: Path.Join("sub", "app.log"));
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(ShellLinkResolver.BuildCandidatePaths(Path.Join(_directory, "link.lnk"), targets),
+ Is.EqualTo(new[] { Path.Join(_directory, "sub", "app.log") }));
+ }
+
+ [TestCase(@"\\server\share\app.log")]
+ [TestCase(@"\\.\pipe\app.log")]
+ [TestCase(@"\\?\C:\logs\app.log")]
+ public void BuildCandidatePaths_NonLocalTarget_IsSkipped (string target)
+ {
+ // Following these would contact another host (leaking the NetNTLM hash) or block on a
+ // device. They must not even become candidates.
+ var data = ShellLinkFixture.Build(localBasePath: target);
+
+ Assert.That(ShellLinkResolver.TryParseTargets(data, out var targets), Is.True);
+ Assert.That(ShellLinkResolver.BuildCandidatePaths(Path.Join(_directory, "link.lnk"), targets), Is.Empty);
+ }
+
+ #endregion
+
+ #region Resolution
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void Resolve_RelativePathTarget_ReturnsTarget (bool unicode)
+ {
+ var target = CreateLog(Path.Join("sub", "app.log"));
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(relativePath: Path.Join("sub", "app.log"), unicode: unicode));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_LocalBasePath_ReturnsTarget ()
+ {
+ var target = CreateLog("app.log");
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(localBasePath: target));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_BasePathWithCommonSuffix_ReturnsTarget ()
+ {
+ var target = CreateLog("app.log");
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(
+ localBasePath: _directory,
+ commonPathSuffix: "app.log"));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_NonShortcutPath_ReturnsUnchanged ()
+ {
+ var missing = Path.Join(_directory, "missing.log");
+
+ Assert.That(ShellLinkResolver.Resolve(missing), Is.EqualTo(missing));
+ }
+
+ [Test]
+ public void Resolve_MissingTarget_ReturnsShortcutPath ()
+ {
+ var shortcut = CreateShortcut("link.lnk", ShellLinkFixture.Build(relativePath: "gone.log"));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ }
+
+ [Test]
+ public void Resolve_ChainOfShortcuts_ReturnsFinalTarget ()
+ {
+ var target = CreateLog("app.log");
+ _ = CreateShortcut("second.lnk", ShellLinkFixture.Build(relativePath: "app.log"));
+ var first = CreateShortcut("first.lnk", ShellLinkFixture.Build(relativePath: "second.lnk"));
+
+ Assert.That(ShellLinkResolver.Resolve(first), Is.EqualTo(target));
+ }
+
+ [Test]
+ public void Resolve_BrokenSecondLink_ReturnsOriginalInput ()
+ {
+ // Not the intermediate shortcut: a half-followed chain is no more usable than the input.
+ _ = CreateShortcut("second.lnk", ShellLinkFixture.Build(relativePath: "gone.log"));
+ var first = CreateShortcut("first.lnk", ShellLinkFixture.Build(relativePath: "second.lnk"));
+
+ Assert.That(ShellLinkResolver.Resolve(first), Is.EqualTo(first));
+ }
+
+ [Test]
+ public void Resolve_Loop_ReturnsOriginalInput ()
+ {
+ var first = CreateShortcut("first.lnk", ShellLinkFixture.Build(relativePath: "second.lnk"));
+ _ = CreateShortcut("second.lnk", ShellLinkFixture.Build(relativePath: "first.lnk"));
+
+ Assert.That(ShellLinkResolver.Resolve(first), Is.EqualTo(first));
+ }
+
+ [Test]
+ public void Resolve_CorruptShortcut_ReturnsUnchanged ()
+ {
+ var shortcut = CreateShortcut("corrupt.lnk", ShellLinkFixture.Build(relativePath: "app.log", corruptSignature: true));
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ }
+
+ [Test]
+ public void Resolve_OversizedFile_ReturnsUnchanged ()
+ {
+ var bytes = new byte[(1024 * 1024) + 1];
+ ShellLinkFixture.Build(relativePath: "app.log").CopyTo(bytes, 0);
+ var shortcut = CreateShortcut("huge.lnk", bytes);
+
+ Assert.That(ShellLinkResolver.Resolve(shortcut), Is.EqualTo(shortcut));
+ }
+
+ #endregion
+}
diff --git a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
index 2625fb94b..dec57ce71 100644
--- a/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
+++ b/src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
@@ -1,3 +1,4 @@
+using System.Buffers.Binary;
using System.Runtime.Versioning;
using System.Text;
@@ -657,6 +658,46 @@ 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 LoadFilesWithOption_SingleLxjFile_CallsProjectCallback ()
{
@@ -1225,6 +1266,42 @@ 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");
+ WriteShortcut(shortcut, "project.lxj");
+
+ return (directory, shortcut, session);
+ }
+
+ ///
+ /// Writes a minimal shortcut: the header, IS_UNICODE | HAS_RELATIVE_PATH, then one StringData
+ /// entry. MS-SHLLINK 2.4 puts no NULL terminator after it.
+ ///
+ private static void WriteShortcut (string shortcutPath, string relativePath)
+ {
+ var bytes = new byte[0x4C + 2 + (relativePath.Length * 2)];
+
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x4C);
+ new byte[] { 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 }
+ .CopyTo(bytes, 4);
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20), 0x00000088);
+ BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(0x4C), (ushort)relativePath.Length);
+ _ = Encoding.Unicode.GetBytes(relativePath, bytes.AsSpan(0x4E));
+
+ File.WriteAllBytes(shortcutPath, bytes);
+ }
+
///
/// 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/Dialogs/LogTabWindow/LogTabWindow.cs b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
index 5ec992ae2..fc3aaf141 100644
--- a/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
+++ b/src/LogExpert.UI/Dialogs/LogTabWindow/LogTabWindow.cs
@@ -170,9 +170,11 @@ public LogTabWindow (string[] fileNames, int instanceNumber, bool showInstanceNu
[SupportedOSPlatform("windows")]
private LogWindow.LogWindow CreateLogWindowFromRequest (FileTabRequest request, EncodingOptions encodingOptions)
{
+ // Resolved by FileOperationService at the entry point; FileName keeps the path the user
+ // picked, which the window reports back as GivenFileName.
LogWindow.LogWindow logWindow = new(
_logWindowCoordinator,
- PersisterHelpers.FindFilenameForSettings(request.FileName, PluginRegistry.PluginRegistry.Instance),
+ request.ResolvedFileName ?? request.FileName,
request.IsTempFile,
request.ForcePersistenceLoading,
ConfigManager,
@@ -205,7 +207,11 @@ private void OnFileOperationServiceFileOpened (object? sender, FileOpenedEventAr
if (!e.Request.IsTempFile)
{
- SetTooltipText(e.LogWindow, e.ResolvedFileName);
+ // Show both sides when they differ: a shortcut or a settings file would otherwise hide
+ // which file the tab is really showing.
+ SetTooltipText(e.LogWindow, string.Equals(e.Request.FileName, e.ResolvedFileName, StringComparison.Ordinal)
+ ? e.ResolvedFileName
+ : $"{e.Request.FileName} → {e.ResolvedFileName}");
}
// Filter tooltip setup
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
index 00a6d5247..e798a8fb6 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs
@@ -68,9 +68,8 @@ internal sealed class FileOperationService (
public LogWindow AddFileTab (FileTabRequest request)
{
- // A shortcut names a file the user did not open: resolve it before anything else, so the tab,
- // the reader and the Session File all deal with the target. The request keeps the original
- // path, so what the user picked is still what is shown and remembered.
+ // The one place a shortcut is resolved: everything downstream opens the target, while the
+ // request keeps the path the user picked for the title, history and recent files.
var requestedFile = ShellLinkResolver.Resolve(request.FileName);
var logFileName = PersisterHelpers.FindFilenameForSettings(requestedFile, _pluginRegistry);
var normalizedPath = Path.GetFullPath(logFileName);
@@ -99,7 +98,8 @@ public LogWindow AddFileTab (FileTabRequest request)
encodingOptions.Encoding = new UnicodeEncoding(false, false);
}
- var logWindow = _logWindowFactory(request, encodingOptions);
+ var tabRequest = request with { ResolvedFileName = logFileName };
+ var logWindow = _logWindowFactory(tabRequest, encodingOptions);
if (!request.IsTempFile)
{
@@ -208,8 +208,8 @@ public LogWindow AddTempFileTab (string fileName, string title)
return null;
}
- // Shortcuts are resolved for the same reason as in AddFileTab: only the reader needs the
- // targets, while the request keeps the path the user dropped.
+ // Resolved here as well: only the reader needs the targets, the request keeps the dropped
+ // paths for history and the title.
var resolvedFileNames = fileNames.Select(ShellLinkResolver.Resolve).ToArray();
// Session files (.lxj) aren't logs — dispatch them to the project loader and
@@ -229,7 +229,8 @@ public LogWindow AddTempFileTab (string fileName, string title)
var request = new FileTabRequest
{
FileName = fileNames[^1],
- IsTempFile = false
+ IsTempFile = false,
+ ResolvedFileName = PersisterHelpers.FindFilenameForSettings(resolvedFileNames[^1], _pluginRegistry)
};
var logWindow = _logWindowFactory(request, encodingOptions);
@@ -278,9 +279,12 @@ 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 resolved = ShellLinkResolver.Resolve(fileNames[0]);
+
+ if (resolved.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
{
- _loadSessionCallback(fileNames[0], true);
+ _loadSessionCallback(resolved, true);
return MultiFileDecision.Cancel; // Already handled
}
@@ -318,9 +322,12 @@ 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 = ShellLinkResolver.Resolve(fileName);
+
+ if (resolved.EndsWith(".lxj", StringComparison.OrdinalIgnoreCase))
{
- _loadSessionCallback(fileName, false);
+ _loadSessionCallback(resolved, false);
}
else
{
diff --git a/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs b/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs
index 0e4180d56..62ef6bbfb 100644
--- a/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs
+++ b/src/LogExpert.UI/Services/FileOperationService/FileTabRequest.cs
@@ -8,10 +8,17 @@ namespace LogExpert.UI.Services.FileOperationService;
internal sealed record FileTabRequest
{
///
- /// The file name as provided by the caller (may be relative, may be a .lxp settings file).
+ /// The file name as provided by the caller (may be relative, may be a .lxp settings file or a
+ /// shortcut). This is what gets shown and remembered.
///
public required string FileName { get; init; }
+ ///
+ /// The file the reader actually opens, after shortcuts and .lxp indirection. Filled in by
+ /// FileOperationService at the entry point; null means "same as ".
+ ///
+ public string? ResolvedFileName { get; init; }
+
/// Optional one-based line to select after loading and Session File restoration.
public int? TargetLine { get; init; }