Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0a312a0
Add initial devcontainer configuration for C# development
stesee May 30, 2025
23a74dd
Update devcontainer configuration to remove additional version specif…
stesee May 30, 2025
7de307c
Refactor Windows-specific test to remove platform attribute
stesee May 30, 2025
1619f20
exFat test experiment
stesee May 30, 2025
f9f485e
Enhance exFAT support in tests and devcontainer configuration
stesee May 30, 2025
00beed6
Add installation step for exfatprogs in Ubuntu workflow
stesee May 30, 2025
a4426e0
Refactor exFAT formatting command to use bash for improved compatibility
stesee May 30, 2025
8ea42ab
Refactor exFAT mounting process to capture output and errors for bett…
stesee May 30, 2025
c3ca207
Update Ubuntu workflow to install exfat-fuse alongside exfatprogs for…
stesee May 30, 2025
c446b43
Add exfat-utils to Ubuntu installation step for enhanced exFAT support
stesee May 30, 2025
aa10068
Remove exfat-utils from Ubuntu installation step for exfatprogs
stesee May 30, 2025
1b791af
Fix comment typo in CustomFsFileWriteAsserter class
stesee May 30, 2025
e7ac59b
Refactor file write assertion tests to use IDisposable pattern and ad…
stesee Jun 1, 2025
e7f1662
Enhance WindowsExFatSpecificTests to check for administrator privileg…
stesee Jun 1, 2025
efe16d7
Add error handling for exFAT VHDX creation failure in FileWriteAsserter
stesee Jun 1, 2025
72cd69e
Improve exFAT partition initialization logic in FileWriteAsserter
stesee Jun 1, 2025
4df1b3b
Refactor WindowsExFatSpecificTests to initialize FileWriteAsserter co…
stesee Jun 1, 2025
45bd79d
Fix exFat setup
stesee Jun 2, 2025
d81acca
Enhance exFAT testing setup and functionality by adding AutoPlayDisab…
stesee Jun 8, 2025
6ae41b6
Update project files and dependencies: fix newline at end of file in …
stesee Jun 8, 2025
37e1c24
Refactor exFAT testing setup: remove unnecessary installation step fo…
stesee Jun 8, 2025
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
10 changes: 10 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "C# Dev Container",
"image": "mcr.microsoft.com/devcontainers/dotnet",
"features": {
"ghcr.io/devcontainers/features/dotnet:2.2.2": {
"version": "9.0"
}
},
"postCreateCommand": "apt-get update && apt-get install -y exfatprogs"
}
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,15 @@ Restrictions of Windows, Linux and OsX are alle combined to an replacement patte
- Edge case Unicode sanitization: [.NET Framework](https://learn.microsoft.com/en-us/dotnet/framework/whats-new/#character-categories) uses Unicode 8.0, while .NET 8+ uses a newer version to detect unpaired surrogates and unassigned code points.
- This is relevant when dealing with emoticons.
- For example, ["💏🏻"](https://emojipedia.org/kiss-light-skin-tone) will be sanitized when running on .NET Framework 4.8, while it is supported as a valid filename on modern filesystems

## Test setup

The ExFat specific tests are skipped as long as no ExFat filesystem is available. Use this snippet to enable them:

```powershell
$vhdpath = 'C:\temp\ExFatTestContainer.vhd'
$vhdsize = 100MB
New-VHD -Path $vhdpath -Dynamic -SizeBytes $vhdsize | Mount-VHD -Passthru |Initialize-Disk -Passthru |New-Partition -AssignDriveLetter -UseMaximumSize |Format-Volume -FileSystem 'exFAT' -Confirm:$false -NewFileSystemLabel '{exfatLabel}' -Force|Out-Null
```

Running as admin will automaticly create and mount a Exfat drive while tests are running.
74 changes: 74 additions & 0 deletions SanitizeFilenameTests/ExFatTooling/AutoPlayDisabledScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using Microsoft.Win32;
using System.Runtime.InteropServices;

public class AutoPlayDisabledScope : IDisposable
{
private const string AutoPlayRegKey = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer";
private const string AutoPlayRegValue = "NoDriveTypeAutoRun";
private const int DisableAllAutoPlay = 0xFF;

private static int? _originalValue;
private bool disposedValue;

public bool AutoPlayerInitialState { get; }

/// <summary>
/// Temporarily disables AutoPlay if it is enabled. Returns true if it was changed.
/// </summary>
public AutoPlayDisabledScope()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
AutoPlayerInitialState = false;
return;
}

object? current = Registry.GetValue(AutoPlayRegKey, AutoPlayRegValue, null);
if (current is int value && value == DisableAllAutoPlay)
{
// Already disabled
AutoPlayerInitialState = false;
return;
}

_originalValue = current as int?;
Registry.SetValue(AutoPlayRegKey, AutoPlayRegValue, DisableAllAutoPlay, RegistryValueKind.DWord);
AutoPlayerInitialState = true;
}

/// <summary>
/// Restores the original AutoPlay setting if it was changed.
/// </summary>
public static void RestoreAutoPlay()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return;
}

if (_originalValue.HasValue)
{
Registry.SetValue(AutoPlayRegKey, AutoPlayRegValue, _originalValue.Value, RegistryValueKind.DWord);
_originalValue = null;
}
}

protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
RestoreAutoPlay();
}

disposedValue = true;
}
}

public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
166 changes: 166 additions & 0 deletions SanitizeFilenameTests/ExFatTooling/ExFatFileWriteAsserterFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System.Runtime.InteropServices;

namespace SanitizeFilenameTests.ExFatTooling
{
public class ExFatFileWriteAsserterFactory : FileWriteAsserter
{
public static FileWriteAsserter? TryGetOrCreateExFatPartition(out string reason)
{
reason = string.Empty;
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
if (TryGetExFatPartition(out string path))
return new FileWriteAsserter(path);

reason = "ExFatFileWriteAsserterFactory is only applicable on Windows.";
return null;
}

if (RuntimeInformation.OSArchitecture == Architecture.Arm || RuntimeInformation.OSArchitecture == Architecture.Arm64)
{
if (TryGetExFatPartition(out string path))
return new FileWriteAsserter(path);

reason = "Test is skipped on Windows ARM because VHD mounting is not supported.";
return null;
}

if (!IsRunningAsAdministrator())
{
if (TryGetExFatPartition(out string path))
return new FileWriteAsserter(path);

reason = "Test requires administrator privileges to create and mount exFAT VHD or an mounted ExFat drive.";
return null;
}

var imageTempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var exFatPartitionCreateInfo = CreateAndMountExFatPartition(imageTempPath);
FileWriteAsserter fileWriteAsserter = new(exFatPartitionCreateInfo.TempPathInExFatPartition, exFatPartitionCreateInfo.VhdxPath);

return fileWriteAsserter;
}

private static bool TryGetExFatPartition(out string path)
{
path = string.Empty;
try
{
foreach (var drive in DriveInfo.GetDrives())
{
if (drive.IsReady && string.Equals(drive.DriveFormat, "exFAT", StringComparison.OrdinalIgnoreCase))
{
string testDir = Path.Combine(drive.RootDirectory.FullName, "test" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(testDir);
path = testDir;
return true;
}
}
}
catch
{
// Ignore exceptions and return false
}
return false;
}

private static bool IsRunningAsAdministrator()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return false;
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}

private static (string TempPathInExFatPartition, string VhdxPath) CreateAndMountExFatPartition(string ImageTempPath)
{
using AutoPlayDisabledScope autoPlayDisabledScope = new AutoPlayDisabledScope();

var vhdxPath = Path.Combine(ImageTempPath, $"exfat-test-{Guid.NewGuid():N}.vhdx");
string vhdxFileName = Path.GetFileName(vhdxPath);
// exFAT volume label max length is 11 characters
string exfatLabel = Guid.NewGuid().ToString("N").Substring(0, 11);

string psScript = $@"

$vhdpath = '{vhdxPath}'
$vhdsize = 100MB
New-VHD -Path $vhdpath -Dynamic -SizeBytes $vhdsize | Mount-VHD -Passthru | Initialize-Disk -Passthru | Out-Null
Start-Sleep -Seconds 2
$disk = Get-Disk | Where-Object {{ $_.Location -like '*{vhdxFileName}*' }}
$partition = New-Partition -DiskNumber $disk.Number -UseMaximumSize -AssignDriveLetter
Format-Volume -Partition $partition -FileSystem 'exFAT' -Confirm:$false -NewFileSystemLabel '{exfatLabel}' -Force | Out-Null
$partition.DriveLetter
";
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{psScript.Replace("\"", "`\"")}\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string stdOut = process.StandardOutput.ReadToEnd().Trim();
string stdErr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0 || !string.IsNullOrEmpty(stdErr))
{
throw new InvalidOperationException(
$"Failed to create or mount exFAT VHDX for testing. Exit code: {process.ExitCode}. Output: {stdOut} Error: {stdErr}"
);
}

if (!string.IsNullOrEmpty(stdOut))
{
var TempPathOnExFat = stdOut + @":\test" + Guid.NewGuid();
if (!Directory.Exists(TempPathOnExFat))
Directory.CreateDirectory(TempPathOnExFat);

return (TempPathOnExFat, vhdxPath);
}
else
{
throw new InvalidOperationException("Failed to create or mount exFAT VHDX for testing.");
}
}

public static void UnmountAndDeleteImage(string exfatVhdxPath)
{
if (!string.IsNullOrEmpty(exfatVhdxPath))
{
// Unmount the exFAT VHDX by its file path
var unmountProcess = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"Dismount-VHD -Path '{exfatVhdxPath}'\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
unmountProcess.Start();
string stdOut = unmountProcess.StandardOutput.ReadToEnd();
string stdErr = unmountProcess.StandardError.ReadToEnd();
unmountProcess.WaitForExit();
if (unmountProcess.ExitCode != 0)
{
throw new InvalidOperationException($"Failed to dismount exFAT VHD. Exit code: {unmountProcess.ExitCode}. Output: {stdOut} Error: {stdErr}");
}

if (File.Exists(exfatVhdxPath))
{
File.Delete(exfatVhdxPath);
}
}
}
}
}
44 changes: 35 additions & 9 deletions SanitizeFilenameTests/FilenameTests/FileWriteAsserter.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
namespace SanitizeFilenameTests
using SanitizeFilenameTests.ExFatTooling;

namespace SanitizeFilenameTests
{
public class FileWriteAsserter
public class FileWriteAsserter : IDisposable
{
public FileWriteAsserter()
private bool disposedValue;

public FileWriteAsserter(string? tempPath = null, string? disposableVhdxPath = null)
{
TempPath = Path.Combine(Path.GetTempPath(), "test" + Guid.NewGuid());
DisposableVhdxPath = disposableVhdxPath;
TempPath = tempPath ?? Path.Combine(Path.GetTempPath(), "test" + Guid.NewGuid());

if (!Directory.Exists(TempPath))
Directory.CreateDirectory(TempPath);
}

public string TempPath { get; }
public string? DisposableVhdxPath { get; }
public string TempPath { get; set; }

internal void AssertCollection(List<(string, int)> validFilenames)
{
Expand All @@ -26,9 +33,6 @@ internal void AssertCollection(List<(string, int)> validFilenames)
}
});

//invalidFilenames.Add(("test", 1));
//invalidFilenames.Add(("test", 2));

Assert.That(invalidFilenames.OrderBy(x => x.Item2), Is.Empty, GenerateAssertionMessage(invalidFilenames));
}

Expand Down Expand Up @@ -62,5 +66,27 @@ private static string GenerateAssertionMessage(List<(string, int)> invalidFilena
{
return "Invalid chars: " + string.Join(", ", invalidFilenames.Select(x => $"{x.Item2}"));
}

protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
if (DisposableVhdxPath != null)
ExFatFileWriteAsserterFactory.UnmountAndDeleteImage(DisposableVhdxPath);
else
Directory.Delete(TempPath, true);
}

disposedValue = true;
}
}

public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}
}
3 changes: 1 addition & 2 deletions SanitizeFilenameTests/FilenameTests/LinuxSpecificTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ public LinuxSpecificTests()
[OneTimeTearDown]
public void TearDown()
{
if (Directory.Exists(FileWriteAsserter.TempPath))
Directory.Delete(FileWriteAsserter.TempPath, true);
FileWriteAsserter.Dispose();
}

[Test]
Expand Down
6 changes: 2 additions & 4 deletions SanitizeFilenameTests/FilenameTests/OsXSpecificTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ public OsXSpecificTests()
[OneTimeTearDown]
public void TearDown()
{
if (Directory.Exists(FileWriteAsserter.TempPath))
Directory.Delete(FileWriteAsserter.TempPath, true);
FileWriteAsserter.Dispose();
}

public FileWriteAsserter FileWriteAsserter { get; }
Expand Down Expand Up @@ -47,7 +46,7 @@ public void MacOsDoesNotSupportToWriteNotAssignedCodepointsWithSurrogates()
[Test]
public void ShouldSanitizeValidSurrogatesWithoutFollowingCodepoint()
{
// https://unicodelookup.com/#557056/1
// https://unicodelookup.com/#557056/1
var oneOfManyValuesFoundByRunningEveryPossibleUTF16ValueAgainstMacOs = 557056;
var sanitizedFilenames = new List<(string, int)>();
string unicodeString = char.ConvertFromUtf32(oneOfManyValuesFoundByRunningEveryPossibleUTF16ValueAgainstMacOs);
Expand All @@ -64,7 +63,6 @@ public void ShouldSanitizeValidSurrogatesWithoutFollowingCodepoint()
[TestCase(3315)]
// U+11F02 Kawi Sign Repha https://codepoints.net/U+11F02
[TestCase(73474)]

public void MacOsSupportToWriteCodePointsThatFailedOnOsXGibhutRunnersInBeginOf2024(int bogusOsXValue)
{
// https://unicodelookup.com/#423939/1
Expand Down
Loading