diff --git a/ImageDescriber.Test/ScanTests.cs b/ImageDescriber.Test/ScanTests.cs index 244b4ff..ddea4bf 100644 --- a/ImageDescriber.Test/ScanTests.cs +++ b/ImageDescriber.Test/ScanTests.cs @@ -87,6 +87,44 @@ public void SanitizeFileNameHandlesSimpleName() Assert.AreEqual("sunset-over-ocean.webp", result.WeakString); } + [TestMethod] + public void SanitizeFileNameReplacesCharactersWindowsRejectsOnEveryPlatform() + { + // Path.GetInvalidFileNameChars() is only "\0" and "/" on Linux and macOS, so this used to + // come back unchanged there and produce a name Windows cannot hold + FileName result = Scan.SanitizeFileName("sunset at 5:30pm? ", ".jpg".As()); + + Assert.AreEqual("sunset at 5-30pm- -draft.jpg", result.WeakString); + } + + [TestMethod] + public void SanitizeFileNameReplacesEveryWindowsReservedCharacterAndControlCharacter() + { + FileName result = Scan.SanitizeFileName("ac:d\"e|f?g*h\\i\tj\u0001k", ".jpg".As()); + + Assert.AreEqual("a-b-c-d-e-f-g-h-i-j-k.jpg", result.WeakString); + } + + [TestMethod] + [DataRow("con")] + [DataRow("NUL")] + [DataRow("Com1")] + [DataRow("lpt9")] + public void SanitizeFileNameAvoidsWindowsDeviceNames(string suggestion) + { + FileName result = Scan.SanitizeFileName(suggestion, ".jpg".As()); + + Assert.AreEqual($"{suggestion}-image.jpg", result.WeakString); + } + + [TestMethod] + public void SanitizeFileNameKeepsNamesThatOnlyStartWithADeviceName() + { + FileName result = Scan.SanitizeFileName("console-on-desk", ".jpg".As()); + + Assert.AreEqual("console-on-desk.jpg", result.WeakString); + } + [TestMethod] public void SanitizeFileNameTakesNameFromInsideCodeFence() { diff --git a/ImageDescriber/Verbs/Scan.cs b/ImageDescriber/Verbs/Scan.cs index 5436b38..38c4ae8 100644 --- a/ImageDescriber/Verbs/Scan.cs +++ b/ImageDescriber/Verbs/Scan.cs @@ -267,11 +267,10 @@ internal static FileName SanitizeFileName(string rawSuggestion, FileExtension ex name = System.IO.Path.GetFileNameWithoutExtension(name); } - // Remove invalid filename characters - foreach (char c in System.IO.Path.GetInvalidFileNameChars()) - { - name = name.Replace(c, '-'); - } + // Remove characters Windows rejects. Path.GetInvalidFileNameChars() is only "\0" and "/" on + // Linux and macOS, and a suggestion is stored once per hash and meant for renaming, so a name + // suggested there has to be legal on Windows, NTFS, SMB and exFAT as well + name = string.Concat(name.Select(c => IsPortableFileNameChar(c) ? c : '-')); // Collapse multiple hyphens and trim while (name.Contains("--", StringComparison.Ordinal)) @@ -286,9 +285,26 @@ internal static FileName SanitizeFileName(string rawSuggestion, FileExtension ex name = "unnamed"; } + // Windows refuses a device name as a file name whatever extension follows it ("con.jpg") + if (WindowsReservedNames.Contains(name)) + { + name = $"{name}-image"; + } + return $"{name}{extension}".As(); } + private static readonly HashSet WindowsReservedNames = new( + [ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ], + StringComparer.OrdinalIgnoreCase); + + private static bool IsPortableFileNameChar(char c) => + c is >= ' ' and not ('<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*'); + // An opening or closing fence ("```" or "```text"), as opposed to a name written inline // between two fences on one line ("```sunset```") private static bool IsCodeFence(string line) =>