From ebde119bf8461e739b93f96be8e5fe73337ec0cf Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:26:00 +0200 Subject: [PATCH 1/6] Add optional extension-preserving truncation Introduce a new `preserveFileNameExtension` parameter (defaulting to `true`) on `Sanitize` and `SanitizeFilename` overloads so long filenames can be truncated while keeping their extension. Update truncation logic to split name/extension and enforce the 255-byte limit on the combined result. Also simplify several legacy `#if NETSTANDARD2_0` branches and add coverage for long-name truncation behavior with and without extension preservation. --- SanitizeFilename/SanitizeFilename.cs | 54 ++++++------- .../SanitizeFilenameExtensions.cs | 10 ++- .../FilenameTests/TrunacateLongFilenames.cs | 75 +++++++++++++++++++ 3 files changed, 104 insertions(+), 35 deletions(-) create mode 100644 SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs diff --git a/SanitizeFilename/SanitizeFilename.cs b/SanitizeFilename/SanitizeFilename.cs index 8e69b5b..1b549ee 100644 --- a/SanitizeFilename/SanitizeFilename.cs +++ b/SanitizeFilename/SanitizeFilename.cs @@ -101,13 +101,14 @@ public static class SanitizeFilename /// /// /// + /// /// - public static string Sanitize(string filename, char replacement = DefaultReplacementChar) + public static string Sanitize(string filename, char replacement = DefaultReplacementChar, bool preserveFileNameExtension = true) { ReplacementSanityCheck(replacement); string saneFilename = InternalSanitize(filename, replacement); - return UnicodeSafeStringTruncate(saneFilename); + return UnicodeSafeStringTruncate(saneFilename, preserveFileNameExtension); } /// @@ -115,14 +116,15 @@ public static string Sanitize(string filename, char replacement = DefaultReplace /// /// /// + /// /// - public static string Sanitize(string filename, string replacement) + public static string Sanitize(string filename, string replacement, bool preserveFileNameExtension = true) { if (char.TryParse(replacement, out var replacementChar)) ReplacementSanityCheck(replacementChar); string saneFilename = InternalSanitize(filename, replacement); - return UnicodeSafeStringTruncate(saneFilename); + return UnicodeSafeStringTruncate(saneFilename, preserveFileNameExtension); } private static void ReplacementSanityCheck(char replacement) @@ -161,19 +163,11 @@ private static string InternalSanitizeChars(string filename, string replacement, foreach (var invalidChar in usedInvalidChars) { -#if NETSTANDARD2_0 - filename = filename.Replace(invalidChar.ToString(), replacement); -#else filename = filename.Replace(invalidChar.ToString(), replacement, StringComparison.Ordinal); -#endif } foreach (var invalidCodePoint in invalidCodePoints ?? []) { -#if NETSTANDARD2_0 - filename = filename.Replace(char.ConvertFromUtf32(invalidCodePoint), replacement); -#else filename = filename.Replace(char.ConvertFromUtf32(invalidCodePoint), replacement, StringComparison.Ordinal); -#endif } filename = RemoveUnpairedSurrogates(filename, replacement); @@ -251,14 +245,7 @@ private static string InternalSanitizeReservedFileNames(string filename, string { foreach (var reservedFileName in ReservedWindowsFileNames) { -#if NETSTANDARD2_0 - filename = filename.Replace(reservedFileName, replacement); -#pragma warning disable CA1308 // Input is predefined and safe to use with ToLoverInvariant - filename = filename.Replace(reservedFileName.ToLowerInvariant(), replacement); -#pragma warning restore CA1308 -#else filename = filename.Replace(reservedFileName, replacement, true, CultureInfo.InvariantCulture); -#endif } return filename; @@ -268,11 +255,7 @@ private static string InternalSanitizeReservedFileNamePrefix(string filename, st { foreach (var reservedFileNamePrefix in ReservedWindowsFileNamesWithExtension) if (filename.StartsWith(reservedFileNamePrefix, true, CultureInfo.InvariantCulture)) -#if NETSTANDARD2_0 - filename = replacement + filename.Substring(0, reservedFileNamePrefix.Length); -#else filename = string.Concat(replacement, filename.AsSpan(0, reservedFileNamePrefix.Length)); -#endif return filename; } @@ -281,16 +264,12 @@ private static string RemoveTrailingPeriodOrSpace(string filename, string replac foreach (var InvalidTrailingChar in InvalidTrailingChars) if (filename.EndsWith(InvalidTrailingChar, true, CultureInfo.InvariantCulture)) { -#if NETSTANDARD2_0 - return filename.Remove(filename.Length - 1, 1); -#else return filename[..^1] + replacement; -#endif } return filename; } - private static string UnicodeSafeStringTruncate(string longFileName) + private static string UnicodeSafeStringTruncate(string longFileName, bool preserveFileNameExtension) { // Most filenames are shorter than 255 bytes, so we can avoid the expensive string enumeration in most cases if (FileNameLengthIsExt4Compatible(longFileName)) @@ -298,19 +277,32 @@ private static string UnicodeSafeStringTruncate(string longFileName) return longFileName; } + // If we should preserve the extension, extract it and handle truncation accordingly + var extension = string.Empty; + var fileNameWithoutExtension = longFileName; + + if (preserveFileNameExtension) + { + extension = Path.GetExtension(longFileName); + fileNameWithoutExtension = Path.GetFileNameWithoutExtension(longFileName); + } + var builder = new StringBuilder(); var builderForward = new StringBuilder(); - var textElementEnumerator = StringInfo.GetTextElementEnumerator(longFileName); + var textElementEnumerator = StringInfo.GetTextElementEnumerator(fileNameWithoutExtension); while (textElementEnumerator.MoveNext()) { builderForward.Append(textElementEnumerator.Current); + // When preserving extension, account for its byte size in the length check + var combinedString = builderForward.ToString() + extension; + // Rule working for EXT4 and most other file systems - if (!FileNameLengthIsExt4Compatible(builderForward.ToString())) + if (!FileNameLengthIsExt4Compatible(combinedString)) { - return builder.ToString(); + return builder.ToString() + extension; } builder.Append(textElementEnumerator.Current); diff --git a/SanitizeFilename/SanitizeFilenameExtensions.cs b/SanitizeFilename/SanitizeFilenameExtensions.cs index 3879c74..94feac3 100644 --- a/SanitizeFilename/SanitizeFilenameExtensions.cs +++ b/SanitizeFilename/SanitizeFilenameExtensions.cs @@ -10,10 +10,11 @@ public static class SanitizeFilenameExtensions /// /// /// + /// /// - public static string SanitizeFilename(this string filename, char replacement = '_') + public static string SanitizeFilename(this string filename, char replacement = '_', bool preserveFileNameExtension = true) { - var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement); + var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, preserveFileNameExtension); return sanitizedFilename; } @@ -23,10 +24,11 @@ public static string SanitizeFilename(this string filename, char replacement = ' /// /// /// + /// /// - public static string SanitizeFilename(this string filename, string replacement) + public static string SanitizeFilename(this string filename, string replacement, bool preserveFileNameExtension = true) { - var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement); + var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, preserveFileNameExtension); return sanitizedFilename; } diff --git a/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs b/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs new file mode 100644 index 0000000..3c08c02 --- /dev/null +++ b/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs @@ -0,0 +1,75 @@ +using Codeuctivity; + +namespace SanitizeFilenameTests.FilenameTests +{ + [Parallelizable(ParallelScope.Fixtures)] + internal class TrunacateLongFilenames : SanitizeFilenamesTestsBase + { + public TrunacateLongFilenames() + { + FileWriteAsserter = new FileWriteAsserter(); + } + + public FileWriteAsserter FileWriteAsserter { get; } + + [OneTimeTearDown] + public void TearDown() + { + FileWriteAsserter.Dispose(); + } + + [Test] + [TestCase(true, "a.txt")] + [TestCase(false, "aaaaaa")] + public void ShouldTruncateFileExtensionSpecificBehaviour(bool preserveFileNameExtension, string exectedSanitizedFilenameEnd) + { + var filename = new string('a', 300); + filename += ".txt"; + var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: preserveFileNameExtension); + + Assert.That(sanitizedFilename, Does.EndWith(exectedSanitizedFilenameEnd)); + Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); + Assert.That(System.Text.Encoding.UTF8.GetByteCount(sanitizedFilename), Is.LessThanOrEqualTo(255)); + } + + [Test] + [TestCase(true, ".aaaaaa")] + [TestCase(false, "file")] + public void ShouldPreserveExtensionEvenWhenExceedingMaxLength(bool preserveFileNameExtension, string exectedSanitizedFilenameStart) + { + // Create a filename where even the extension alone exceeds the max length when combined with minimal filename + var veryLongExtension = "." + new string('a', 300); // 251 bytes for extension alone + var filename = "file" + veryLongExtension; + var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: preserveFileNameExtension); + + // Should preserve the extension despite length constraints + Assert.That(sanitizedFilename, Does.StartWith(exectedSanitizedFilenameStart)); + Assert.That(System.Text.Encoding.UTF8.GetByteCount(sanitizedFilename), Is.LessThanOrEqualTo(255)); + Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); + } + + [Test] + [TestCase("shortname.txt")] + [TestCase("another.doc")] + [TestCase("file.json")] + public void ShouldPreserveExtensionForShortFileNames(string filename) + { + var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: true); + + var extension = Path.GetExtension(filename); + Assert.That(sanitizedFilename, Does.EndWith(extension)); + Assert.That(sanitizedFilename, Is.EqualTo(filename)); + Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); + } + + [Test] + public void ShouldHandleFileWithoutExtension() + { + var filename = new string('a', 300); // No extension + var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: true); + + Assert.That(System.Text.Encoding.UTF8.GetByteCount(sanitizedFilename), Is.LessThanOrEqualTo(255)); + Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); + } + } +} \ No newline at end of file From 3657bef0d24e68c0dc8663c2be6ed1268027cac5 Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:38:00 +0200 Subject: [PATCH 2/6] Replace bool param with FilenameExtensionHandling enum Replaces the `preserveFileNameExtension` boolean parameter with a `FilenameExtensionHandling` enum, adding a third option to throw when the extension is too long to fit within the 255-byte limit. Updates all call sites and tests accordingly. --- SanitizeFilename/FilenameExtensionHandling.cs | 24 ++++++++++ SanitizeFilename/SanitizeFilename.cs | 46 +++++++++++++++---- .../SanitizeFilenameExtensions.cs | 12 ++--- .../FilenameTests/TrunacateLongFilenames.cs | 33 +++++++++---- 4 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 SanitizeFilename/FilenameExtensionHandling.cs diff --git a/SanitizeFilename/FilenameExtensionHandling.cs b/SanitizeFilename/FilenameExtensionHandling.cs new file mode 100644 index 0000000..8484890 --- /dev/null +++ b/SanitizeFilename/FilenameExtensionHandling.cs @@ -0,0 +1,24 @@ +namespace Codeuctivity +{ + /// + /// Defines how file extensions should be handled when truncating long filenames. + /// + public enum FilenameExtensionHandling + { + /// + /// Preserve the file extension, truncating only the base filename if necessary to fit within the 255-byte limit. + /// If the extension itself is too long, it will also be truncated to ensure the total filename fits. + /// + PreserveFilenameExtension = 0, + + /// + /// Do not preserve the file extension. Truncate the entire filename including the extension. + /// + PreserveFilenameWithoutExtension = 1, + + /// + /// Throw an exception if the filename extension is too long to fit within the 255-byte limit. + /// + ThrowWhenFilenameExtensionIsTooLong = 2 + } +} diff --git a/SanitizeFilename/SanitizeFilename.cs b/SanitizeFilename/SanitizeFilename.cs index 1b549ee..b515f34 100644 --- a/SanitizeFilename/SanitizeFilename.cs +++ b/SanitizeFilename/SanitizeFilename.cs @@ -101,14 +101,14 @@ public static class SanitizeFilename /// /// /// - /// + /// /// - public static string Sanitize(string filename, char replacement = DefaultReplacementChar, bool preserveFileNameExtension = true) + public static string Sanitize(string filename, char replacement = DefaultReplacementChar, FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension) { ReplacementSanityCheck(replacement); string saneFilename = InternalSanitize(filename, replacement); - return UnicodeSafeStringTruncate(saneFilename, preserveFileNameExtension); + return UnicodeSafeStringTruncate(saneFilename, filenameExtensionHandling); } /// @@ -116,15 +116,15 @@ public static string Sanitize(string filename, char replacement = DefaultReplace /// /// /// - /// + /// /// - public static string Sanitize(string filename, string replacement, bool preserveFileNameExtension = true) + public static string Sanitize(string filename, string replacement, FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension) { if (char.TryParse(replacement, out var replacementChar)) ReplacementSanityCheck(replacementChar); string saneFilename = InternalSanitize(filename, replacement); - return UnicodeSafeStringTruncate(saneFilename, preserveFileNameExtension); + return UnicodeSafeStringTruncate(saneFilename, filenameExtensionHandling); } private static void ReplacementSanityCheck(char replacement) @@ -269,7 +269,7 @@ private static string RemoveTrailingPeriodOrSpace(string filename, string replac return filename; } - private static string UnicodeSafeStringTruncate(string longFileName, bool preserveFileNameExtension) + private static string UnicodeSafeStringTruncate(string longFileName, FilenameExtensionHandling filenameExtensionHandling) { // Most filenames are shorter than 255 bytes, so we can avoid the expensive string enumeration in most cases if (FileNameLengthIsExt4Compatible(longFileName)) @@ -281,7 +281,7 @@ private static string UnicodeSafeStringTruncate(string longFileName, bool preser var extension = string.Empty; var fileNameWithoutExtension = longFileName; - if (preserveFileNameExtension) + if (filenameExtensionHandling != FilenameExtensionHandling.PreserveFilenameWithoutExtension) { extension = Path.GetExtension(longFileName); fileNameWithoutExtension = Path.GetFileNameWithoutExtension(longFileName); @@ -302,7 +302,35 @@ private static string UnicodeSafeStringTruncate(string longFileName, bool preser // Rule working for EXT4 and most other file systems if (!FileNameLengthIsExt4Compatible(combinedString)) { - return builder.ToString() + extension; + var truncatedFileName = builder.ToString() + extension; + + // If even the extension alone exceeds the limit, we need to handle it based on the setting + if (!FileNameLengthIsExt4Compatible(truncatedFileName)) + { + if (filenameExtensionHandling == FilenameExtensionHandling.ThrowWhenFilenameExtensionIsTooLong) + { + throw new ArgumentException($"The filename extension is too long to fit within the 255-byte filesystem limit.", nameof(longFileName)); + } + + // Truncate the extension to fit within the 255-byte limit + var extensionBuilder = new StringBuilder(); + var extensionEnumerator = StringInfo.GetTextElementEnumerator(extension); + + while (extensionEnumerator.MoveNext()) + { + var testExtension = extensionBuilder.ToString() + extensionEnumerator.Current; + var testFullName = builder.ToString() + testExtension; + + if (!FileNameLengthIsExt4Compatible(testFullName)) + { + return builder.ToString() + extensionBuilder.ToString(); + } + + extensionBuilder.Append(extensionEnumerator.Current); + } + } + + return truncatedFileName; } builder.Append(textElementEnumerator.Current); diff --git a/SanitizeFilename/SanitizeFilenameExtensions.cs b/SanitizeFilename/SanitizeFilenameExtensions.cs index 94feac3..19f1bab 100644 --- a/SanitizeFilename/SanitizeFilenameExtensions.cs +++ b/SanitizeFilename/SanitizeFilenameExtensions.cs @@ -10,11 +10,11 @@ public static class SanitizeFilenameExtensions /// /// /// - /// + /// /// - public static string SanitizeFilename(this string filename, char replacement = '_', bool preserveFileNameExtension = true) + public static string SanitizeFilename(this string filename, char replacement = '_', FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension) { - var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, preserveFileNameExtension); + var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, filenameExtensionHandling); return sanitizedFilename; } @@ -24,11 +24,11 @@ public static string SanitizeFilename(this string filename, char replacement = ' /// /// /// - /// + /// /// - public static string SanitizeFilename(this string filename, string replacement, bool preserveFileNameExtension = true) + public static string SanitizeFilename(this string filename, string replacement, FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension) { - var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, preserveFileNameExtension); + var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, filenameExtensionHandling); return sanitizedFilename; } diff --git a/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs b/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs index 3c08c02..5d8c27f 100644 --- a/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs +++ b/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs @@ -19,13 +19,13 @@ public void TearDown() } [Test] - [TestCase(true, "a.txt")] - [TestCase(false, "aaaaaa")] - public void ShouldTruncateFileExtensionSpecificBehaviour(bool preserveFileNameExtension, string exectedSanitizedFilenameEnd) + [TestCase(FilenameExtensionHandling.PreserveFilenameExtension, "a.txt")] + [TestCase(FilenameExtensionHandling.PreserveFilenameWithoutExtension, "aaaaaa")] + public void ShouldTruncateFileExtensionSpecificBehaviour(FilenameExtensionHandling handling, string exectedSanitizedFilenameEnd) { var filename = new string('a', 300); filename += ".txt"; - var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: preserveFileNameExtension); + var sanitizedFilename = filename.SanitizeFilename(filenameExtensionHandling: handling); Assert.That(sanitizedFilename, Does.EndWith(exectedSanitizedFilenameEnd)); Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); @@ -33,14 +33,14 @@ public void ShouldTruncateFileExtensionSpecificBehaviour(bool preserveFileNameEx } [Test] - [TestCase(true, ".aaaaaa")] - [TestCase(false, "file")] - public void ShouldPreserveExtensionEvenWhenExceedingMaxLength(bool preserveFileNameExtension, string exectedSanitizedFilenameStart) + [TestCase(FilenameExtensionHandling.PreserveFilenameExtension, ".aaaaaa")] + [TestCase(FilenameExtensionHandling.PreserveFilenameWithoutExtension, "file")] + public void ShouldPreserveExtensionEvenWhenExceedingMaxLength(FilenameExtensionHandling handling, string exectedSanitizedFilenameStart) { // Create a filename where even the extension alone exceeds the max length when combined with minimal filename var veryLongExtension = "." + new string('a', 300); // 251 bytes for extension alone var filename = "file" + veryLongExtension; - var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: preserveFileNameExtension); + var sanitizedFilename = filename.SanitizeFilename(filenameExtensionHandling: handling); // Should preserve the extension despite length constraints Assert.That(sanitizedFilename, Does.StartWith(exectedSanitizedFilenameStart)); @@ -54,7 +54,7 @@ public void ShouldPreserveExtensionEvenWhenExceedingMaxLength(bool preserveFileN [TestCase("file.json")] public void ShouldPreserveExtensionForShortFileNames(string filename) { - var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: true); + var sanitizedFilename = filename.SanitizeFilename(filenameExtensionHandling: FilenameExtensionHandling.PreserveFilenameExtension); var extension = Path.GetExtension(filename); Assert.That(sanitizedFilename, Does.EndWith(extension)); @@ -66,10 +66,23 @@ public void ShouldPreserveExtensionForShortFileNames(string filename) public void ShouldHandleFileWithoutExtension() { var filename = new string('a', 300); // No extension - var sanitizedFilename = filename.SanitizeFilename(preserveFileNameExtension: true); + var sanitizedFilename = filename.SanitizeFilename(filenameExtensionHandling: FilenameExtensionHandling.PreserveFilenameExtension); Assert.That(System.Text.Encoding.UTF8.GetByteCount(sanitizedFilename), Is.LessThanOrEqualTo(255)); Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); } + + [Test] + public void ShouldThrowWhenFilenameExtensionIsTooLong() + { + // Create a filename where the extension is very long + var veryLongExtension = "." + new string('a', 300); + var filename = "file" + veryLongExtension; + + var ex = Assert.Throws(() => + filename.SanitizeFilename(filenameExtensionHandling: FilenameExtensionHandling.ThrowWhenFilenameExtensionIsTooLong)); + + Assert.That(ex.Message, Does.Contain("extension is too long")); + } } } \ No newline at end of file From 5652e1047162d2a5583899a3a8de40f022d17873 Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:45:27 +0200 Subject: [PATCH 3/6] Add dotnetfiddle link to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f82e4f9..72fc496 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Console.WriteLine($"SafeFileNameOptionalReplacementChar: {safeFileNameOptionalRe //SafeFileNameOptionalReplacementChar: file Name ``` +Try it yourself: [dotnetfiddle.net/bFWqX0](https://dotnetfiddle.net/bFWqX0) + ## Rules Restrictions of Windows, Linux and macOS are all combined to an replacement pattern, that will sanitize any filename to be compatible with any of the OS and common filesystem restrictions. From ccaed78a111301c6c68d65b235988c1cf167418a Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:46:41 +0200 Subject: [PATCH 4/6] Remove trailing whitespace in test file --- SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs b/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs index 5d8c27f..895cc86 100644 --- a/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs +++ b/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs @@ -79,7 +79,7 @@ public void ShouldThrowWhenFilenameExtensionIsTooLong() var veryLongExtension = "." + new string('a', 300); var filename = "file" + veryLongExtension; - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => filename.SanitizeFilename(filenameExtensionHandling: FilenameExtensionHandling.ThrowWhenFilenameExtensionIsTooLong)); Assert.That(ex.Message, Does.Contain("extension is too long")); From 308c06bd8079d11ddcf7f1e631ac0b9192a9e8f6 Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:28:46 +0000 Subject: [PATCH 5/6] Rename enum value for clarity and add unit tests for filename truncation behavior --- SanitizeFilename/FilenameExtensionHandling.cs | 2 +- SanitizeFilename/SanitizeFilename.cs | 2 +- ...ngFilenames.cs => TruncateLongFilenames.cs} | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) rename SanitizeFilenameTests/FilenameTests/{TrunacateLongFilenames.cs => TruncateLongFilenames.cs} (84%) diff --git a/SanitizeFilename/FilenameExtensionHandling.cs b/SanitizeFilename/FilenameExtensionHandling.cs index 8484890..7fb8684 100644 --- a/SanitizeFilename/FilenameExtensionHandling.cs +++ b/SanitizeFilename/FilenameExtensionHandling.cs @@ -14,7 +14,7 @@ public enum FilenameExtensionHandling /// /// Do not preserve the file extension. Truncate the entire filename including the extension. /// - PreserveFilenameWithoutExtension = 1, + DoNotPreserveFilenameExtension = 1, /// /// Throw an exception if the filename extension is too long to fit within the 255-byte limit. diff --git a/SanitizeFilename/SanitizeFilename.cs b/SanitizeFilename/SanitizeFilename.cs index b515f34..07a8d26 100644 --- a/SanitizeFilename/SanitizeFilename.cs +++ b/SanitizeFilename/SanitizeFilename.cs @@ -281,7 +281,7 @@ private static string UnicodeSafeStringTruncate(string longFileName, FilenameExt var extension = string.Empty; var fileNameWithoutExtension = longFileName; - if (filenameExtensionHandling != FilenameExtensionHandling.PreserveFilenameWithoutExtension) + if (filenameExtensionHandling != FilenameExtensionHandling.DoNotPreserveFilenameExtension) { extension = Path.GetExtension(longFileName); fileNameWithoutExtension = Path.GetFileNameWithoutExtension(longFileName); diff --git a/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs b/SanitizeFilenameTests/FilenameTests/TruncateLongFilenames.cs similarity index 84% rename from SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs rename to SanitizeFilenameTests/FilenameTests/TruncateLongFilenames.cs index 895cc86..8549e80 100644 --- a/SanitizeFilenameTests/FilenameTests/TrunacateLongFilenames.cs +++ b/SanitizeFilenameTests/FilenameTests/TruncateLongFilenames.cs @@ -3,9 +3,9 @@ namespace SanitizeFilenameTests.FilenameTests { [Parallelizable(ParallelScope.Fixtures)] - internal class TrunacateLongFilenames : SanitizeFilenamesTestsBase + internal class TruncateLongFilenames : SanitizeFilenamesTestsBase { - public TrunacateLongFilenames() + public TruncateLongFilenames() { FileWriteAsserter = new FileWriteAsserter(); } @@ -20,30 +20,30 @@ public void TearDown() [Test] [TestCase(FilenameExtensionHandling.PreserveFilenameExtension, "a.txt")] - [TestCase(FilenameExtensionHandling.PreserveFilenameWithoutExtension, "aaaaaa")] - public void ShouldTruncateFileExtensionSpecificBehaviour(FilenameExtensionHandling handling, string exectedSanitizedFilenameEnd) + [TestCase(FilenameExtensionHandling.DoNotPreserveFilenameExtension, "aaaaaa")] + public void ShouldTruncateFileExtensionSpecificBehaviour(FilenameExtensionHandling handling, string expectedSanitizedFilenameEnd) { var filename = new string('a', 300); filename += ".txt"; var sanitizedFilename = filename.SanitizeFilename(filenameExtensionHandling: handling); - Assert.That(sanitizedFilename, Does.EndWith(exectedSanitizedFilenameEnd)); + Assert.That(sanitizedFilename, Does.EndWith(expectedSanitizedFilenameEnd)); Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); Assert.That(System.Text.Encoding.UTF8.GetByteCount(sanitizedFilename), Is.LessThanOrEqualTo(255)); } [Test] [TestCase(FilenameExtensionHandling.PreserveFilenameExtension, ".aaaaaa")] - [TestCase(FilenameExtensionHandling.PreserveFilenameWithoutExtension, "file")] - public void ShouldPreserveExtensionEvenWhenExceedingMaxLength(FilenameExtensionHandling handling, string exectedSanitizedFilenameStart) + [TestCase(FilenameExtensionHandling.DoNotPreserveFilenameExtension, "file")] + public void ShouldPreserveExtensionEvenWhenExceedingMaxLength(FilenameExtensionHandling handling, string expectedSanitizedFilenameStart) { // Create a filename where even the extension alone exceeds the max length when combined with minimal filename - var veryLongExtension = "." + new string('a', 300); // 251 bytes for extension alone + var veryLongExtension = "." + new string('a', 300); // 301 bytes for extension alone var filename = "file" + veryLongExtension; var sanitizedFilename = filename.SanitizeFilename(filenameExtensionHandling: handling); // Should preserve the extension despite length constraints - Assert.That(sanitizedFilename, Does.StartWith(exectedSanitizedFilenameStart)); + Assert.That(sanitizedFilename, Does.StartWith(expectedSanitizedFilenameStart)); Assert.That(System.Text.Encoding.UTF8.GetByteCount(sanitizedFilename), Is.LessThanOrEqualTo(255)); Assert.That(FileWriteAsserter.TryWriteFileToTempDirectory(sanitizedFilename), Is.True); } From 43855d9a56186c62008834188d0953791a743fac Mon Sep 17 00:00:00 2001 From: Stefan Seeland <168659+stesee@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:38:35 +0000 Subject: [PATCH 6/6] Add test job for multiple OS and upload test artifacts --- .github/workflows/dotnet.yml | 38 +++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index a7102f4..710618b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -24,7 +24,7 @@ jobs: run: dotnet restore - name: Build run: dotnet build --configuration Release --no-restore - - name: Test + - name: Test on Ubuntu (no build) run: dotnet test --no-build --verbosity normal --configuration Release - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -32,11 +32,43 @@ jobs: name: release-artifacts path: SanitizeFilename/bin/Release/ retention-days: 1 + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + with: + name: test-artifacts + path: SanitizeFilenameTests/bin/Release/ + retention-days: 1 + + test: + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - macos-latest + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + - name: Restore dependencies + run: dotnet restore + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: test-artifacts + path: SanitizeFilenameTests/bin/Release/ + - name: Test (no build) + run: dotnet test --no-build --verbosity normal --configuration Release deployRelease: if: startsWith(github.ref, 'refs/heads/release') runs-on: ubuntu-latest - needs: build + needs: [build, test] steps: - uses: actions/download-artifact@v4 with: @@ -59,7 +91,7 @@ jobs: deployTest: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - needs: build + needs: [build, test] steps: - uses: actions/download-artifact@v4 with: