diff --git a/QuickLook.Common/Helpers/PathHelper.cs b/QuickLook.Common/Helpers/PathHelper.cs
new file mode 100644
index 000000000..5b6e6d6c0
--- /dev/null
+++ b/QuickLook.Common/Helpers/PathHelper.cs
@@ -0,0 +1,172 @@
+// Copyright © 2017-2026 QL-Win Contributors
+//
+// This file is part of QuickLook program.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.IO;
+
+namespace QuickLook.Common.Helpers;
+
+public static class PathHelper
+{
+ private static readonly char[] TrimmableExtensionChars = ['"', ' ', '\t', '\r', '\n'];
+
+ public static string GetExtension(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return string.Empty;
+
+ var normalized = Normalize(path);
+
+ // Path.GetExtension validates the whole path through Path.CheckInvalidPathChars
+ // and can throw for shell-provided paths. Parse UNC paths directly so they
+ // always reach the preview pipeline.
+ if (IsUncPath(normalized))
+ return GetExtensionFallback(normalized);
+
+ try
+ {
+ return Path.GetExtension(normalized);
+ }
+ catch (ArgumentException)
+ {
+ return GetExtensionFallback(normalized);
+ }
+ }
+
+ public static string GetFileName(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return path;
+
+ var normalized = Normalize(path);
+
+ try
+ {
+ return Path.GetFileName(normalized);
+ }
+ catch (ArgumentException)
+ {
+ var separator = normalized.LastIndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]);
+ return separator < 0 ? normalized : normalized.Substring(separator + 1);
+ }
+ }
+
+ public static string GetPathRoot(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return string.Empty;
+
+ var normalized = Normalize(path);
+
+ try
+ {
+ return Path.GetPathRoot(normalized);
+ }
+ catch (ArgumentException)
+ {
+ if (normalized.StartsWith(@"\\", StringComparison.Ordinal))
+ {
+ var parts = normalized.Substring(2).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], 3);
+ return parts.Length switch
+ {
+ 0 => string.Empty,
+ 1 => @"\\" + parts[0],
+ _ => @"\\" + parts[0] + @"\" + parts[1],
+ };
+ }
+
+ if (normalized.Length >= 2 && normalized[1] == Path.VolumeSeparatorChar)
+ return normalized.Substring(0, 2) + Path.DirectorySeparatorChar;
+
+ return string.Empty;
+ }
+ }
+
+ public static DateTime? GetLastWriteTime(string path)
+ {
+ try
+ {
+ var last = File.GetLastWriteTime(path);
+ if (last.Year == 1601)
+ {
+ // File.GetLastWriteTime returns the 1601 sentinel for missing or unreadable paths.
+ return null;
+ }
+
+ return last;
+ }
+ catch (Exception e) when (e is ArgumentException ||
+ e is UnauthorizedAccessException ||
+ e is IOException ||
+ e is NotSupportedException)
+ {
+ // An unusable path must not hide the rest of the info card.
+ return null;
+ }
+ }
+
+ public static bool IsShortcut(string path)
+ {
+ return GetExtension(path).Equals(".lnk", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public static bool IsUncPath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return false;
+
+ var normalized = Normalize(path);
+ return normalized.Length >= 2 &&
+ normalized[0] == Path.DirectorySeparatorChar &&
+ normalized[1] == Path.DirectorySeparatorChar;
+ }
+
+ private static string Normalize(string path)
+ {
+ path = path.Trim();
+
+ if (path.Length >= 2 && path[0] == '"' && path[path.Length - 1] == '"')
+ path = path.Substring(1, path.Length - 2).Trim();
+
+ return path;
+ }
+
+ private static string GetExtensionFallback(string path)
+ {
+ var end = path.Length - 1;
+
+ while (end >= 0 && char.IsWhiteSpace(path[end]))
+ end--;
+
+ for (var i = end; i >= 0; i--)
+ {
+ var c = path[i];
+
+ if (c == '.')
+ return i == end
+ ? string.Empty
+ : path.Substring(i, end - i + 1).TrimEnd(TrimmableExtensionChars);
+
+ if (c == Path.DirectorySeparatorChar ||
+ c == Path.AltDirectorySeparatorChar ||
+ c == Path.VolumeSeparatorChar)
+ break;
+ }
+
+ return string.Empty;
+ }
+}
diff --git a/QuickLook.Tests/PathHelperTests.cs b/QuickLook.Tests/PathHelperTests.cs
new file mode 100644
index 000000000..0516d8239
--- /dev/null
+++ b/QuickLook.Tests/PathHelperTests.cs
@@ -0,0 +1,136 @@
+// Copyright © 2017-2026 QL-Win Contributors
+//
+// This file is part of QuickLook program.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using QuickLook.Common.Helpers;
+using System;
+using System.IO;
+
+namespace QuickLook.Tests;
+
+[TestClass]
+public sealed class PathHelperTests
+{
+ [TestMethod]
+ [DataRow(@"\\server\share\file.png", ".png")]
+ [DataRow(@"\\server.domain\share\folder\image.JPG", ".JPG")]
+ [DataRow(@"\\10.1.2.3\share\file.pdf", ".pdf")]
+ [DataRow(@"\\wsl$\Ubuntu\home\user\file.png", ".png")]
+ [DataRow(@"\\wsl.localhost\Ubuntu\home\user\file.png", ".png")]
+ [DataRow(@"\\server\share\folder", "")]
+ [DataRow(@"\\wsl$\Ubuntu\home\user", "")]
+ [DataRow(@"C:\local\folder\file.png", ".png")]
+ [DataRow(@"Z:\mapped\folder\file.png", ".png")]
+ [DataRow(@"C:\local\folder\file", "")]
+ [DataRow("", "")]
+ public void GetExtensionHandlesUncAndLocalPaths(string path, string expected)
+ {
+ Assert.AreEqual(expected, PathHelper.GetExtension(path));
+ }
+
+ [TestMethod]
+ [DataRow(@"\\server\share\file.png" + "\n")]
+ [DataRow(@"\\server\share\bad""name.png")]
+ [DataRow(@"""\\server\share\file.png""")]
+ public void GetExtensionDoesNotThrowOnMalformedUncPaths(string path)
+ {
+ Assert.AreEqual(".png", PathHelper.GetExtension(path));
+ }
+
+ [TestMethod]
+ [DataRow(@"\\server\share\bad""name.png")]
+ [DataRow(@"""\\server\share\file.png""")]
+ [DataRow("")]
+ public void GetLastWriteTimeReturnsNullForMalformedPaths(string path)
+ {
+ Assert.IsNull(PathHelper.GetLastWriteTime(path));
+ }
+
+ [TestMethod]
+ public void GetLastWriteTimeReturnsNullForUnsupportedPathFormat()
+ {
+ Assert.IsNull(PathHelper.GetLastWriteTime(@"C::\file.png"));
+ }
+
+ [TestMethod]
+ public void GetLastWriteTimeReturnsNullForMissingPath()
+ {
+ var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".missing");
+ Assert.IsNull(PathHelper.GetLastWriteTime(path));
+ }
+
+ [TestMethod]
+ public void GetLastWriteTimeReturnsValueForExistingFile()
+ {
+ var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".tmp");
+ File.WriteAllText(path, string.Empty);
+
+ try
+ {
+ Assert.IsTrue(PathHelper.GetLastWriteTime(path).HasValue);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [TestMethod]
+ [DataRow(@"\\server\share\file.lnk", true)]
+ [DataRow(@"\\server\share\file.LNK", true)]
+ [DataRow(@"\\wsl$\Ubuntu\home\file.png", false)]
+ [DataRow(@"C:\local\folder\file.lnk", true)]
+ [DataRow(@"C:\local\folder\file.png", false)]
+ [DataRow(@"\\wsl$\Ubuntu\home", false)]
+ public void IsShortcutHandlesUncAndLocalPaths(string path, bool expected)
+ {
+ Assert.AreEqual(expected, PathHelper.IsShortcut(path));
+ }
+
+ [TestMethod]
+ [DataRow(@"\\server\share\file.png", true)]
+ [DataRow(@"\\wsl$\Ubuntu\home\file.png", true)]
+ [DataRow(@"""\\server\share\file.png""", true)]
+ [DataRow(@"C:\local\folder\file.png", false)]
+ [DataRow(@"Z:\mapped\file.png", false)]
+ [DataRow(@"\server\share\file.png", false)]
+ [DataRow("", false)]
+ public void IsUncPathHandlesUncAndLocalPaths(string path, bool expected)
+ {
+ Assert.AreEqual(expected, PathHelper.IsUncPath(path));
+ }
+
+ [TestMethod]
+ [DataRow(@"\\server\share\folder\file.png", "file.png")]
+ [DataRow(@"\\wsl$\Ubuntu\home\user\file.png", "file.png")]
+ [DataRow(@"C:\local\folder\file.png", "file.png")]
+ [DataRow(@"\\server\share\bad""name.png", "bad\"name.png")]
+ public void GetFileNameHandlesUncAndMalformedPaths(string path, string expected)
+ {
+ Assert.AreEqual(expected, PathHelper.GetFileName(path));
+ }
+
+ [TestMethod]
+ [DataRow(@"\\server\share\folder\file.png", @"\\server\share")]
+ [DataRow(@"\\wsl$\Ubuntu\home\user\file.png", @"\\wsl$\Ubuntu")]
+ [DataRow(@"C:\local\folder\file.png", @"C:\")]
+ [DataRow(@"Z:\mapped\file.png", @"Z:\")]
+ public void GetPathRootHandlesUncAndLocalPaths(string path, string expected)
+ {
+ Assert.AreEqual(expected, PathHelper.GetPathRoot(path));
+ }
+}
diff --git a/QuickLook.Tests/QuickLook.Tests.csproj b/QuickLook.Tests/QuickLook.Tests.csproj
new file mode 100644
index 000000000..78c89aac4
--- /dev/null
+++ b/QuickLook.Tests/QuickLook.Tests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net462
+ false
+ latest
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/QuickLook/Helpers/ExtensionFilterHelper.cs b/QuickLook/Helpers/ExtensionFilterHelper.cs
index 5b4c4a9f3..cd7817691 100644
--- a/QuickLook/Helpers/ExtensionFilterHelper.cs
+++ b/QuickLook/Helpers/ExtensionFilterHelper.cs
@@ -147,7 +147,7 @@ public static bool IsExtensionAllowed(string path)
if (string.IsNullOrEmpty(path))
return true;
- var extension = Path.GetExtension(path);
+ var extension = PathHelper.GetExtension(path);
// Files without extensions are always allowed (includes directories)
if (string.IsNullOrEmpty(extension))
diff --git a/QuickLook/NativeMethods/QuickLook.cs b/QuickLook/NativeMethods/QuickLook.cs
index 0f6bfaf4e..d2a5dadfc 100644
--- a/QuickLook/NativeMethods/QuickLook.cs
+++ b/QuickLook/NativeMethods/QuickLook.cs
@@ -15,6 +15,7 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
+using QuickLook.Common.Helpers;
using System;
using System.Diagnostics;
using System.IO;
@@ -133,14 +134,23 @@ private static string ResolveShortcut(string path)
{
if (string.IsNullOrEmpty(path)) return path;
- if (Path.GetExtension(path).ToLower() != ".lnk") return path;
+ if (!PathHelper.IsShortcut(path)) return path;
- var link = new ShellLink();
- ((IPersistFile)link).Load(path, 0);
- var sb = new StringBuilder(MaxPath);
- ((IShellLinkW)link).GetPath(sb, sb.Capacity, out _, 0);
+ try
+ {
+ var link = new ShellLink();
+ ((IPersistFile)link).Load(path, 0);
+ var sb = new StringBuilder(MaxPath);
+ ((IShellLinkW)link).GetPath(sb, sb.Capacity, out _, 0);
- return sb.Length == 0 ? path : sb.ToString();
+ return sb.Length == 0 ? path : sb.ToString();
+ }
+ catch (Exception e)
+ {
+ // An unreadable shortcut must not break the whole preview.
+ Debug.WriteLine(e);
+ return path;
+ }
}
internal enum FocusedWindowType
diff --git a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs
index e4c1ce1dd..4c017f5b4 100644
--- a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs
+++ b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs
@@ -58,64 +58,73 @@ public void DisplayInfo(string path)
Dispatcher.BeginInvoke(new Action(() => image.Source = source));
});
- var name = Path.GetFileName(path);
+ var name = PathHelper.GetFileName(path);
filename.Text = string.IsNullOrEmpty(name) ? path : name;
- var last = File.GetLastWriteTime(path);
- modDate.Text = string.Format(TranslationHelper.Get("InfoPanel_LastModified"),
- last.ToString(CultureInfo.CurrentCulture));
+ var last = PathHelper.GetLastWriteTime(path);
+ modDate.Text = last.HasValue
+ ? string.Format(TranslationHelper.Get("InfoPanel_LastModified"),
+ last.Value.ToString(CultureInfo.CurrentCulture))
+ : string.Empty;
Stop = false;
_ = Task.Run(() =>
{
- if (File.Exists(path))
+ try
{
- var size = new FileInfo(path).Length;
-
- Dispatcher.Invoke(() => { totalSize.Text = size.ToPrettySize(2); });
- }
- else if (Path.GetPathRoot(path) == path) // is this a drive?
- {
- FileHelper.GetDriveSpace(path, out var totalSpace, out var totalFreeSpace);
+ if (File.Exists(path))
+ {
+ var size = new FileInfo(path).Length;
- Dispatcher.Invoke(() =>
+ Dispatcher.Invoke(() => { totalSize.Text = size.ToPrettySize(2); });
+ }
+ else if (PathHelper.GetPathRoot(path) == path) // is this a drive?
{
- totalSize.Text =
- string.Format(TranslationHelper.Get("InfoPanel_DriveSize"),
- totalSpace.ToPrettySize(2),
- totalFreeSpace.ToPrettySize(2));
- });
- }
- else if (Directory.Exists(path))
- {
- FileHelper.CountFolder(path, ref _stop,
- out var totalDirsL, out var totalFilesL, out var totalSizeL);
+ FileHelper.GetDriveSpace(path, out var totalSpace, out var totalFreeSpace);
- if (!Stop)
Dispatcher.Invoke(() =>
{
- string t;
- var folders = totalDirsL == 0
- ? string.Empty
- : string.Format(TranslationHelper.Get(
- totalDirsL == 1 ? "InfoPanel_Folder" : "InfoPanel_Folders"), totalDirsL);
- var files = totalFilesL == 0
- ? string.Empty
- : string.Format(TranslationHelper.Get(
- totalFilesL == 1 ? "InfoPanel_File" : "InfoPanel_Files"), totalFilesL);
-
- if (!string.IsNullOrEmpty(folders) && !string.IsNullOrEmpty(files))
- t = string.Format(
- TranslationHelper.Get("InfoPanel_FolderAndFile"), folders, files);
- else if (string.IsNullOrEmpty(folders) && string.IsNullOrEmpty(files))
- t = string.Empty;
- else
- t = $"({folders}{files})";
-
totalSize.Text =
- $"{totalSizeL.ToPrettySize(2)} {t}";
+ string.Format(TranslationHelper.Get("InfoPanel_DriveSize"),
+ totalSpace.ToPrettySize(2),
+ totalFreeSpace.ToPrettySize(2));
});
+ }
+ else if (Directory.Exists(path))
+ {
+ FileHelper.CountFolder(path, ref _stop,
+ out var totalDirsL, out var totalFilesL, out var totalSizeL);
+
+ if (!Stop)
+ Dispatcher.Invoke(() =>
+ {
+ string t;
+ var folders = totalDirsL == 0
+ ? string.Empty
+ : string.Format(TranslationHelper.Get(
+ totalDirsL == 1 ? "InfoPanel_Folder" : "InfoPanel_Folders"), totalDirsL);
+ var files = totalFilesL == 0
+ ? string.Empty
+ : string.Format(TranslationHelper.Get(
+ totalFilesL == 1 ? "InfoPanel_File" : "InfoPanel_Files"), totalFilesL);
+
+ if (!string.IsNullOrEmpty(folders) && !string.IsNullOrEmpty(files))
+ t = string.Format(
+ TranslationHelper.Get("InfoPanel_FolderAndFile"), folders, files);
+ else if (string.IsNullOrEmpty(folders) && string.IsNullOrEmpty(files))
+ t = string.Empty;
+ else
+ t = $"({folders}{files})";
+
+ totalSize.Text =
+ $"{totalSizeL.ToPrettySize(2)} {t}";
+ });
+ }
+ }
+ catch (ArgumentException)
+ {
+ // The info card stays usable even when the path cannot be inspected.
}
});
}