From 40d1ed965b140a29e6012b0726bd976f1f2b284d Mon Sep 17 00:00:00 2001 From: Henglie Date: Tue, 3 Nov 2026 13:35:38 +0800 Subject: [PATCH 1/3] Fix preview crashing on UNC and malformed paths - Parse extensions without Path.CheckInvalidPathChars for UNC paths. - Return the original path when a shortcut cannot be resolved. - Guard the info card against path parsing failures. - Add regression tests covering SMB/WSL UNC, local, and malformed paths. Fixes #2012 --- QuickLook.Common/Helpers/PathHelper.cs | 149 +++++++++++++++++++ QuickLook.Tests/PathHelperTests.cs | 96 ++++++++++++ QuickLook.Tests/QuickLook.Tests.csproj | 20 +++ QuickLook/Helpers/ExtensionFilterHelper.cs | 2 +- QuickLook/NativeMethods/QuickLook.cs | 22 ++- QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs | 98 +++++++----- 6 files changed, 339 insertions(+), 48 deletions(-) create mode 100644 QuickLook.Common/Helpers/PathHelper.cs create mode 100644 QuickLook.Tests/PathHelperTests.cs create mode 100644 QuickLook.Tests/QuickLook.Tests.csproj diff --git a/QuickLook.Common/Helpers/PathHelper.cs b/QuickLook.Common/Helpers/PathHelper.cs new file mode 100644 index 000000000..fb54a24d3 --- /dev/null +++ b/QuickLook.Common/Helpers/PathHelper.cs @@ -0,0 +1,149 @@ +// 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 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..f3be9bfb8 --- /dev/null +++ b/QuickLook.Tests/PathHelperTests.cs @@ -0,0 +1,96 @@ +// 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; + +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\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..0903157f9 100644 --- a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs +++ b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs @@ -58,10 +58,19 @@ 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); + DateTime last; + try + { + last = File.GetLastWriteTime(path); + } + catch (ArgumentException) + { + last = DateTime.MinValue; + } + modDate.Text = string.Format(TranslationHelper.Get("InfoPanel_LastModified"), last.ToString(CultureInfo.CurrentCulture)); @@ -69,53 +78,60 @@ public void DisplayInfo(string path) _ = 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. } }); } From 0066c533d728eb4431d611a4995b1633bddd8677 Mon Sep 17 00:00:00 2001 From: Henglie Date: Fri, 13 Nov 2026 20:38:43 +0800 Subject: [PATCH 2/3] Handle unreadable last-write time in info panel --- QuickLook.Common/Helpers/PathHelper.cs | 16 ++++++++++ QuickLook.Tests/PathHelperTests.cs | 33 ++++++++++++++++++++ QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs | 17 +++------- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/QuickLook.Common/Helpers/PathHelper.cs b/QuickLook.Common/Helpers/PathHelper.cs index fb54a24d3..e6a39d59c 100644 --- a/QuickLook.Common/Helpers/PathHelper.cs +++ b/QuickLook.Common/Helpers/PathHelper.cs @@ -96,6 +96,22 @@ public static string GetPathRoot(string path) } } + public static DateTime? GetLastWriteTime(string path) + { + try + { + return File.GetLastWriteTime(path); + } + 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); diff --git a/QuickLook.Tests/PathHelperTests.cs b/QuickLook.Tests/PathHelperTests.cs index f3be9bfb8..aa9470e6e 100644 --- a/QuickLook.Tests/PathHelperTests.cs +++ b/QuickLook.Tests/PathHelperTests.cs @@ -17,6 +17,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using QuickLook.Common.Helpers; +using System; +using System.IO; namespace QuickLook.Tests; @@ -49,6 +51,37 @@ 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 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)] diff --git a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs index 0903157f9..4c017f5b4 100644 --- a/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs +++ b/QuickLook/Plugin/InfoPanel/InfoPanel.xaml.cs @@ -61,18 +61,11 @@ public void DisplayInfo(string path) var name = PathHelper.GetFileName(path); filename.Text = string.IsNullOrEmpty(name) ? path : name; - DateTime last; - try - { - last = File.GetLastWriteTime(path); - } - catch (ArgumentException) - { - last = DateTime.MinValue; - } - - 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; From c85b76f8d34f9c0c5d9ea3e2b1a626b68a20b0f0 Mon Sep 17 00:00:00 2001 From: Henglie Date: Fri, 13 Nov 2026 20:44:20 +0800 Subject: [PATCH 3/3] Treat missing file time sentinel as unreadable --- QuickLook.Common/Helpers/PathHelper.cs | 9 ++++++++- QuickLook.Tests/PathHelperTests.cs | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/QuickLook.Common/Helpers/PathHelper.cs b/QuickLook.Common/Helpers/PathHelper.cs index e6a39d59c..5b6e6d6c0 100644 --- a/QuickLook.Common/Helpers/PathHelper.cs +++ b/QuickLook.Common/Helpers/PathHelper.cs @@ -100,7 +100,14 @@ public static string GetPathRoot(string path) { try { - return File.GetLastWriteTime(path); + 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 || diff --git a/QuickLook.Tests/PathHelperTests.cs b/QuickLook.Tests/PathHelperTests.cs index aa9470e6e..0516d8239 100644 --- a/QuickLook.Tests/PathHelperTests.cs +++ b/QuickLook.Tests/PathHelperTests.cs @@ -66,6 +66,13 @@ 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() {