From f423e6d2621de762f8929ea124216f8208c11b44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:55:28 +0000 Subject: [PATCH] Fix NTFS filename character sanitization on non-Windows systems Replace OS-specific Path.GetInvalidFileNameChars() with a comprehensive list of characters invalid on common filesystems (NTFS, FAT32, ext4, etc.) to ensure files can be created on any filesystem regardless of host OS. - Add InvalidFileNameChars array with all NTFS/Windows forbidden chars - Add comprehensive unit tests for filename sanitization - Fixes issue where '?', ':', '*', '<', '>', '|', '"', '\' were not sanitized on Linux Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- .../Specs/PathSanitizationSpecs.cs | 129 ++++++++++++++++++ .../Utils/Extensions/PathExtensions.cs | 22 ++- 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 DiscordChatExporter.Cli.Tests/Specs/PathSanitizationSpecs.cs diff --git a/DiscordChatExporter.Cli.Tests/Specs/PathSanitizationSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/PathSanitizationSpecs.cs new file mode 100644 index 00000000..fd534dea --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/Specs/PathSanitizationSpecs.cs @@ -0,0 +1,129 @@ +using System.IO; +using DiscordChatExporter.Core.Utils.Extensions; +using FluentAssertions; +using Xunit; + +namespace DiscordChatExporter.Cli.Tests.Specs; + +public class PathSanitizationSpecs +{ + [Fact] + public void Path_with_question_mark_is_sanitized() + { + // Act + var result = Path.EscapeFileName("How to do this?"); + + // Assert + result.Should().Be("How to do this_"); + } + + [Fact] + public void Path_with_colon_is_sanitized() + { + // Act + var result = Path.EscapeFileName("Title: Subtitle"); + + // Assert + result.Should().Be("Title_ Subtitle"); + } + + [Fact] + public void Path_with_asterisk_is_sanitized() + { + // Act + var result = Path.EscapeFileName("File*Name"); + + // Assert + result.Should().Be("File_Name"); + } + + [Fact] + public void Path_with_backslash_is_sanitized() + { + // Act + var result = Path.EscapeFileName("Path\\File"); + + // Assert + result.Should().Be("Path_File"); + } + + [Fact] + public void Path_with_forward_slash_is_sanitized() + { + // Act + var result = Path.EscapeFileName("Path/File"); + + // Assert + result.Should().Be("Path_File"); + } + + [Fact] + public void Path_with_double_quote_is_sanitized() + { + // Act + var result = Path.EscapeFileName("Say \"Hello\""); + + // Assert + result.Should().Be("Say _Hello_"); + } + + [Fact] + public void Path_with_less_than_is_sanitized() + { + // Act + var result = Path.EscapeFileName("Value<10"); + + // Assert + result.Should().Be("Value_10"); + } + + [Fact] + public void Path_with_greater_than_is_sanitized() + { + // Act + var result = Path.EscapeFileName("Value>10"); + + // Assert + result.Should().Be("Value_10"); + } + + [Fact] + public void Path_with_pipe_is_sanitized() + { + // Act + var result = Path.EscapeFileName("Option A | Option B"); + + // Assert + result.Should().Be("Option A _ Option B"); + } + + [Fact] + public void Path_with_multiple_invalid_characters_is_sanitized() + { + // Act + var result = Path.EscapeFileName("How? Why: This/That |right|"); + + // Assert + result.Should().Be("How_ Why_ This_That _works_ _right_"); + } + + [Fact] + public void Path_with_valid_characters_is_not_changed() + { + // Act + var result = Path.EscapeFileName("Valid File Name 123"); + + // Assert + result.Should().Be("Valid File Name 123"); + } + + [Fact] + public void Path_with_special_but_valid_characters_is_not_changed() + { + // Act + var result = Path.EscapeFileName("File (with) [brackets] & symbols! @#$%^"); + + // Assert + result.Should().Be("File (with) [brackets] & symbols! @#$%^"); + } +} diff --git a/DiscordChatExporter.Core/Utils/Extensions/PathExtensions.cs b/DiscordChatExporter.Core/Utils/Extensions/PathExtensions.cs index a7c24048..c2fbd505 100644 --- a/DiscordChatExporter.Core/Utils/Extensions/PathExtensions.cs +++ b/DiscordChatExporter.Core/Utils/Extensions/PathExtensions.cs @@ -1,11 +1,31 @@ using System; using System.IO; +using System.Linq; using System.Text; namespace DiscordChatExporter.Core.Utils.Extensions; public static class PathExtensions { + // Characters that are invalid on common filesystems. + // This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS). + // We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters, + // not filesystem-specific characters. For example, on Linux, '?' is valid in filenames, but not on NTFS. + // https://github.com/Tyrrrz/DiscordChatExporter/issues/1417 + private static readonly char[] InvalidFileNameChars = + [ + '\0', // Null character - invalid on all filesystems + '/', // Path separator on Unix + '\\', // Path separator on Windows + ':', // Reserved on Windows (drive letters, NTFS streams) + '*', // Wildcard on Windows + '?', // Wildcard on Windows + '"', // Reserved on Windows + '<', // Redirection on Windows + '>', // Redirection on Windows + '|', // Pipe on Windows + ]; + extension(Path) { public static string EscapeFileName(string path) @@ -13,7 +33,7 @@ public static class PathExtensions var buffer = new StringBuilder(path.Length); foreach (var c in path) - buffer.Append(!Path.GetInvalidFileNameChars().Contains(c) ? c : '_'); + buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); // File names cannot end with a dot on Windows // https://github.com/Tyrrrz/DiscordChatExporter/issues/977