Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,51 @@ 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
with:
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:
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions SanitizeFilename/FilenameExtensionHandling.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Codeuctivity
{
/// <summary>
/// Defines how file extensions should be handled when truncating long filenames.
/// </summary>
public enum FilenameExtensionHandling
{
/// <summary>
/// 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.
/// </summary>
PreserveFilenameExtension = 0,

/// <summary>
/// Do not preserve the file extension. Truncate the entire filename including the extension.
/// </summary>
DoNotPreserveFilenameExtension = 1,

/// <summary>
/// Throw an exception if the filename extension is too long to fit within the 255-byte limit.
/// </summary>
ThrowWhenFilenameExtensionIsTooLong = 2
}
}
82 changes: 51 additions & 31 deletions SanitizeFilename/SanitizeFilename.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,28 +101,30 @@ public static class SanitizeFilename
/// </summary>
/// <param name="filename"></param>
/// <param name="replacement"></param>
/// <param name="filenameExtensionHandling"></param>
/// <returns></returns>
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);
}
Comment thread
stesee marked this conversation as resolved.

/// <summary>
/// Sanitizes a file name or directory name by replacing invalid chars or reserved char combinations with a replacement char. Follows rules defined in https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
/// </summary>
/// <param name="filename"></param>
/// <param name="replacement"></param>
/// <param name="filenameExtensionHandling"></param>
/// <returns></returns>
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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -281,36 +264,73 @@ 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))
{
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);
Expand Down
10 changes: 6 additions & 4 deletions SanitizeFilename/SanitizeFilenameExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ public static class SanitizeFilenameExtensions
/// </summary>
/// <param name="filename"></param>
/// <param name="replacement"></param>
/// <param name="filenameExtensionHandling"></param>
/// <returns></returns>
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);
Comment thread
stesee marked this conversation as resolved.

return sanitizedFilename;
}
Expand All @@ -23,10 +24,11 @@ public static string SanitizeFilename(this string filename, char replacement = '
/// </summary>
/// <param name="filename"></param>
/// <param name="replacement"></param>
/// <param name="filenameExtensionHandling"></param>
/// <returns></returns>
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;
}
Expand Down
88 changes: 88 additions & 0 deletions SanitizeFilenameTests/FilenameTests/TruncateLongFilenames.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() =>
filename.SanitizeFilename(filenameExtensionHandling: FilenameExtensionHandling.ThrowWhenFilenameExtensionIsTooLong));

Assert.That(ex.Message, Does.Contain("extension is too long"));
}
}
}
Loading