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