Skip to content

Resolve Windows shortcuts (.lnk) to their target file when opening - #748

Open
mfkeeq199458 wants to merge 4 commits into
LogExperts:Developmentfrom
mfkeeq199458:feature/lnk-shortcut-resolution
Open

mfkeeq199458 wants to merge 4 commits into
LogExperts:Developmentfrom
mfkeeq199458:feature/lnk-shortcut-resolution

Conversation

@mfkeeq199458

Copy link
Copy Markdown
Contributor

Opening or dropping a shortcut now opens the log file it points at instead of the shortcut itself. Resolution uses the shell's own IShellLink; UNC and device targets as well as oversized files are not followed and open as regular files.

Opening or dropping a shortcut now opens the log file it points at instead
of the shortcut itself. Resolution uses the shell's own IShellLink; UNC and
device targets as well as oversized files are not followed and open as
regular files.

@Hirogen Hirogen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mfkeeq199458 — nice feature, and good call to avoid IShellLink::Resolve entirely (no link tracking / network search) and to keep the target, not the .lnk, in file history. All entry paths (drop, command line, second-instance pipe, history, session restore, multi-file) go through it.

A few things before merging:

Security — UNC guard
target.StartsWith(@"\\") (ShortcutResolver.cs:95) only catches the canonical form. With SLGP_RAWPATH the target string comes straight from the .lnk bytes, so a hand-crafted link with //evil/share/x.log or \??\UNC\evil\share\x.log would get past the check and reach File.Exists, which is exactly the NetNTLM leak the comment describes. Could you normalise first (e.g. Path.GetFullPath) and then reject anything that isn't a rooted local drive path (Path.GetPathRoot is X:\, not UNC/device)? A test with forward slashes and with \\?\ / \\.\ prefixes would lock this in.

Use Vanara instead of hand-rolled COM
LogExpert.UI already references Vanara.PInvoke.Shell32, which provides ShellLink / IShellLinkW. Using it would drop the ~40 lines of [ComImport] declarations and the hand-ordered IPersistFile vtable, and match how the rest of the codebase does interop.

Path handling

  • SLGP_RAWPATH leaves %VAR% targets unexpanded. Please call Environment.ExpandEnvironmentVariables (or drop RAWPATH).
  • Relative targets should be resolved against the .lnk's directory, not the working directory.
  • The 260-char buffer truncates long-path targets; consider a larger buffer.

Behaviour

  • Shortcuts are resolved twice on the LoadFilesWithOption/AddFileTabs path (and again in AddFileTab), so a link-to-link is followed 2 hops there but 1 hop from history. Resolving in one place would make it consistent.
  • Array.Sort runs before resolution (:281), so multi-file order follows shortcut names rather than target names. Resolve, then sort.
  • When a shortcut can't be followed (UNC, broken, oversized) it currently opens the .lnk as binary garbage with only a log line. A short message to the user would be friendlier. Not blocking, but worth considering.

Small cleanups

  • The "resolve then check .lxj" pattern is repeated three times in FileOperationService; a small helper would tidy it.
  • COMException already derives from ExternalException in the catch filter.
  • var at :79, a using System.Text; instead of the fully qualified StringBuilder, and the comments in FileOperationService that restate the code can go.
  • Tests: CreateShortcut is duplicated across both test files, and the oversized test hardcodes 1024 * 1024 + 1. Consider exposing the limit as internal and reusing it. Please also release the WScript.Shell COM objects.

Thanks — the UNC check and Vanara are the two I'd like to see addressed; the rest is polish.

Security: normalize the raw .lnk target (env-var expansion, resolve
relative to the shortcut directory, Path.GetFullPath) before any
existence probe, and only follow plain local drive targets (X:\).
Disguised UNC/device forms (//host/share, \?\UNC\, \.\C:\) are now
rejected, closing the NetNTLM leak via File.Exists. The guard is a
pure internal SanitizeLocalTarget, unit-tested directly for the
forward-slash and \?\ / \. \ forms the shell parser cannot be fed.

Interop: replace hand-rolled [ComImport] declarations with Vanara's
CShellLinkW/IShellLinkW, matching the rest of the codebase.

Path handling: expand environment variables, resolve relative targets
against the .lnk's directory, widen the GetPath buffer to 1024.

Behaviour: sort after resolving so multi-file order follows target
names; resolution is one hop; the repeated resolve-then-.lxj pattern
in FileOperationService is consolidated into a ResolveLogFile helper.

Tests: shared TestShortcuts helper with proper COM release; oversized
test uses the internal MAX_SHORTCUT_SIZE; new guard tests lock in the
UNC/device-path rejection.
@mfkeeq199458
mfkeeq199458 requested a review from Hirogen October 23, 2026 10:46

@Hirogen Hirogen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mfkeeq199458 — big improvement. The guard is now string-only (expand → GetFullPath against the shortcut's directory → require an X:\ root), so nothing touches the network before the check. The tests for //host/share, \\?\UNC, \??\UNC, \\.\ and %LOGONSERVER% cover exactly what I was worried about. Vanara, env-var expansion, the relative-target handling, resolve-then-sort and the ResolveLogFile helper all look good, and the new test classes pass locally (85/85).

One functional thing left, plus some polish:

Hop count (please fix)
The commit message says "resolution is one hop", but a shortcut is still resolved several times along one call chain:

  • drop or command line, single file: LoadFilesWithOption loop (:280) → ResolveLogFile (:349) → AddFileTab (:71) = 3×
  • SingleFiles / AskUser: pre-resolve at :280 → ResolveLogFile → AddFileTab = 3×
  • history and session restore: AddFileTab only = 1×

So a link-to-link chain is followed 1 to 3 hops depending on how the file was opened, and each step loads a COM object. The simplest fix is to make Resolve idempotent: if the target itself ends in .lnk, treat it as unfollowable and return the input. Then repeated calls are cheap no-ops (they short-circuit on the extension) and the hop count is always exactly one. Then please drop the redundant call sites. Resolving in ResolveLogFile and AddFileTab (plus AddMultiFileTab) should cover every path. A test with a shortcut pointing to another shortcut would lock this in.

Small things

  • ShortcutResolver.cs:~86: please release the CShellLinkW in a finally (Marshal.FinalReleaseComObject), as TestShortcuts already does.
  • FileOperationService.cs:71-75: the string.Equals check isn't needed. request = request with { FileName = ShortcutResolver.Resolve(request.FileName) }; is enough.
  • LoadFilesWithOption now overwrites the caller's array with resolved paths, and LoadDroppedFiles relies on that implicitly. Fine if it stays, but it becomes moot once the extra resolves are gone.
  • Comments: we keep them minimal in this repo. The class remarks, the per-constant doc comments, and the NetNTLM explanation, which appears twice, could shrink to a line or two. The 7-line test class summary can go.
  • Test names: Sanitize_* → SanitizeLocalTarget_*, to match Resolve_*.
  • The PR description says "oversized files are not followed", but the check is on the .lnk size, not the target. Please adjust the wording.

Mapped network drives: a target on a mapped drive (Z:\…) passes the guard, so File.Exists will contact that server. That's fine: the user mapped the drive themselves, so it's a trusted location. No change needed. A one-line comment at the root check saying this is intentional would help the next reader.

Once the hop count is consistent, I'm happy to merge. The rest is polish. Thanks!

mfkeeq199458 and others added 2 commits October 24, 2026 09:55
Shortcut (.lnk) handling now follows a chain of shortcuts to its final
existing local file, bounded by MAX_SHORTCUT_HOPS = 3, and the same
resolution is applied at every entry point (AddFileTab, AddMultiFileTab,
LoadFilesWithOption, ResolveLogFile).

This corrects the earlier PR description:
- resolution is no longer described as a single hop: a valid chain is
  followed to its end; a broken, non-local or over-limit chain keeps the
  last resolvable path (the original shortcut when the hop cap is hit) so
  it still opens as before
- an oversized .lnk (above 1 MiB) is not resolved, but it is not skipped
  either; it is opened as the file the user selected

Review follow-ups:
- release the shell link COM object in a finally block
- remove a redundant string.Equals
- document that mapped network drives are intentionally trusted
- resolve before sorting in LoadFilesWithOption so multi-file order follows
  the target names
- trim duplicated comments
- rename Sanitize_* tests to SanitizeLocalTarget_*

Chain, limit and cycle behaviour is covered through an injected one-hop
resolver, because the Windows shell dereferences .lnk targets when saving.
All 389 LogExpert.UI tests pass.
@mfkeeq199458
mfkeeq199458 requested a review from Hirogen October 24, 2026 06:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants