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:
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.
diff --git a/SanitizeFilename/FilenameExtensionHandling.cs b/SanitizeFilename/FilenameExtensionHandling.cs
new file mode 100644
index 0000000..7fb8684
--- /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.
+ ///
+ DoNotPreserveFilenameExtension = 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 8e69b5b..07a8d26 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, FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension)
{
ReplacementSanityCheck(replacement);
string saneFilename = InternalSanitize(filename, replacement);
- return UnicodeSafeStringTruncate(saneFilename);
+ return UnicodeSafeStringTruncate(saneFilename, filenameExtensionHandling);
}
///
@@ -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, FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension)
{
if (char.TryParse(replacement, out var replacementChar))
ReplacementSanityCheck(replacementChar);
string saneFilename = InternalSanitize(filename, replacement);
- return UnicodeSafeStringTruncate(saneFilename);
+ return UnicodeSafeStringTruncate(saneFilename, filenameExtensionHandling);
}
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, FilenameExtensionHandling filenameExtensionHandling)
{
// Most filenames are shorter than 255 bytes, so we can avoid the expensive string enumeration in most cases
if (FileNameLengthIsExt4Compatible(longFileName))
@@ -298,19 +277,60 @@ 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 (filenameExtensionHandling != FilenameExtensionHandling.DoNotPreserveFilenameExtension)
+ {
+ 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();
+ 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 3879c74..19f1bab 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 = '_', FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension)
{
- var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement);
+ var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, filenameExtensionHandling);
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, FilenameExtensionHandling filenameExtensionHandling = FilenameExtensionHandling.PreserveFilenameExtension)
{
- var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement);
+ var sanitizedFilename = Codeuctivity.SanitizeFilename.Sanitize(filename, replacement, filenameExtensionHandling);
return sanitizedFilename;
}
diff --git a/SanitizeFilenameTests/FilenameTests/TruncateLongFilenames.cs b/SanitizeFilenameTests/FilenameTests/TruncateLongFilenames.cs
new file mode 100644
index 0000000..8549e80
--- /dev/null
+++ b/SanitizeFilenameTests/FilenameTests/TruncateLongFilenames.cs
@@ -0,0 +1,88 @@
+using Codeuctivity;
+
+namespace SanitizeFilenameTests.FilenameTests
+{
+ [Parallelizable(ParallelScope.Fixtures)]
+ internal class TruncateLongFilenames : SanitizeFilenamesTestsBase
+ {
+ public TruncateLongFilenames()
+ {
+ FileWriteAsserter = new FileWriteAsserter();
+ }
+
+ public FileWriteAsserter FileWriteAsserter { get; }
+
+ [OneTimeTearDown]
+ public void TearDown()
+ {
+ FileWriteAsserter.Dispose();
+ }
+
+ [Test]
+ [TestCase(FilenameExtensionHandling.PreserveFilenameExtension, "a.txt")]
+ [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(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.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); // 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(expectedSanitizedFilenameStart));
+ 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(filenameExtensionHandling: FilenameExtensionHandling.PreserveFilenameExtension);
+
+ 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(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