diff --git a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs index 24f7894cd..965daf471 100644 --- a/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs +++ b/src/LogExpert.Core/Classes/Persister/PersisterHelpers.cs @@ -10,8 +10,8 @@ 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. + /// 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) diff --git a/src/LogExpert.Core/Helpers/ShellLinkResolver.cs b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs new file mode 100644 index 000000000..ff4f74d59 --- /dev/null +++ b/src/LogExpert.Core/Helpers/ShellLinkResolver.cs @@ -0,0 +1,566 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text; + +using NLog; + +namespace LogExpert.Core.Helpers; + +/// +/// Resolves a Windows shortcut (.lnk) to the local file it points at. +/// +/// +/// 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 +{ + #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; + + /// + /// 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 + /// 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; + + /// + /// 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 + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + /// + /// 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); + + #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 the whole chain resolves to an existing local file; otherwise + /// unchanged. + /// + public static string Resolve (string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return fileName; + } + + var current = fileName; + + for (var depth = 0; depth < MAX_RESOLVE_DEPTH; depth++) + { + if (!IsShortcutFile(current)) + { + return current; + } + + var target = TryResolveSingle(current); + if (target is null) + { + // 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; + } + + if (IsLocalFilePath(path)) + { + result.Add(path); + } + else + { + _logger.Debug("Not following non-local shortcut target {Target}", path); + } + } + + return result; + } + + #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 IsShortcutFile (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 target that is itself a + /// shortcut. + /// + /// The first candidate that exists on disk, or null. + private static string? TryResolveSingle (string shortcutPath) + { + var data = TryReadShortcut(shortcutPath); + if (data is null) + { + return null; + } + + if (!TryParseTargets(data, out var targets)) + { + _logger.Warn("Ignoring {Shortcut}: not a valid shortcut", shortcutPath); + return null; + } + + return FindExistingTarget(shortcutPath, targets); + } + + private static byte[]? TryReadShortcut (string shortcutPath) + { + try + { + using var stream = new FileStream(shortcutPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + 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; + } + + var bytes = new byte[stream.Length]; + stream.ReadExactly(bytes); + return bytes; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Security.SecurityException) + { + _logger.Debug(ex, "Could not read shortcut {Shortcut}", shortcutPath); + return null; + } + } + + 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 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, ShortcutTargets 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); + + position += (int)linkInfoSize; + return true; + } + + /// + /// 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 followed by that many characters, with + /// no terminator (MS-SHLLINK 2.4). + /// + private static bool TryReadStringData (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; + return true; + } + + /// + /// Returns the first candidate path that exists as a file. + /// + private static string? FindExistingTarget (string shortcutPath, ShortcutTargets targets) + { + foreach (var candidate in BuildCandidatePaths(shortcutPath, targets)) + { + try + { + if (File.Exists(candidate)) + { + return candidate; + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Security.SecurityException) + { + _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)) + { + return; + } + + if (!candidates.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + candidates.Add(path); + } + } + + /// + /// 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 carries, as parsed. A plain holder: resolving them against the + /// file system is 's job. + /// + internal sealed class ShortcutTargets + { + public string? LocalBasePath { get; set; } + + public string? CommonPathSuffix { get; set; } + + public string? RelativePath { get; set; } + } + + #endregion +} 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 0c72551c6..e798a8fb6 100644 --- a/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs +++ b/src/LogExpert.UI/Services/FileOperationService/FileOperationService.cs @@ -68,7 +68,10 @@ internal sealed class FileOperationService ( public LogWindow AddFileTab (FileTabRequest request) { - var logFileName = PersisterHelpers.FindFilenameForSettings(request.FileName, _pluginRegistry); + // 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); var existingWindow = FindWindowForFile(normalizedPath); @@ -95,16 +98,17 @@ 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) { 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; } + // 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 // 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; } @@ -221,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); @@ -235,9 +244,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; @@ -270,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 } @@ -310,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; }