diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml index dc02efb..e7c31f7 100644 --- a/.github/workflows/benchmark.yaml +++ b/.github/workflows/benchmark.yaml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Run benchmark run: cd ForceOps.Benchmarks && dotnet run -c release --exporters json --filter '*' - name: Store benchmark result diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4789539..b1db5db 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,7 +20,7 @@ jobs: - name: Install Dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Dotnet Installation Info run: dotnet --info diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1b55531..1f6a3c7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,7 +33,7 @@ jobs: - name: Install Dotnet uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Directory structure run: | @@ -61,7 +61,7 @@ jobs: with: draft: true name: "${{ steps.get_version.outputs.version }}" - files: ForceOps/bin_aot/Release/net9.0/win-x64/publish/ForceOps.exe + files: ForceOps/bin_aot/Release/net10.0/win-x64/publish/ForceOps.exe tag_name: "${{ steps.get_version.outputs.version }}" - name: Publish NuGet diff --git a/Directory.Build.props b/Directory.Build.props index d05dc2c..3f6137d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ By hook or by crook, perform operations on files and directories. If they are in use by a process, kill the process. - 1.5.1 + 1.6.0 https://github.com/domsleee/forceops https://github.com/domsleee/forceops git @@ -11,7 +11,7 @@ https://github.com/domsleee/forceops/blob/main/CHANGELOG.md false - net9.0 + net10.0 LatestMajor latest enable diff --git a/ForceOps.Lib/src/DirectoryUtils.cs b/ForceOps.Lib/src/DirectoryUtils.cs index 5b3368e..29be860 100644 --- a/ForceOps.Lib/src/DirectoryUtils.cs +++ b/ForceOps.Lib/src/DirectoryUtils.cs @@ -1,11 +1,28 @@ -namespace ForceOps.Lib; +using System.Text.RegularExpressions; + +namespace ForceOps.Lib; public static class DirectoryUtils { + static readonly Regex ReservedDeviceNamePattern = new( + @"^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(\..+)?$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static string CombineWithCWDAndGetAbsolutePath(string path) { string currentDirectory = Directory.GetCurrentDirectory(); - return Path.GetFullPath(Path.Combine(currentDirectory, path)); + string combined = Path.Combine(currentDirectory, path); + + // Path.GetFullPath resolves reserved device names (NUL, CON, etc.) to \\.\NUL. + // Resolve the parent directory instead and re-append the filename. + string fileName = Path.GetFileName(combined); + if (IsReservedDeviceName(fileName)) + { + string parentDir = Path.GetFullPath(Path.GetDirectoryName(combined)!); + return Path.Combine(parentDir, fileName); + } + + return Path.GetFullPath(combined); } public static bool IsSymLink(string folder) => IsSymLink(new DirectoryInfo(folder)); @@ -22,4 +39,29 @@ public static void MarkAsNotReadOnly(FileSystemInfo fileSystemInfo) fileSystemInfo.Attributes &= ~FileAttributes.ReadOnly; } } + + public static bool IsReservedDeviceName(string path) + { + var fileName = Path.GetFileName(path); + return ReservedDeviceNamePattern.IsMatch(fileName); + } + + /// + /// Attempt to delete a file with a Windows reserved device name (NUL, CON, PRN, etc.) + /// using the \\?\ extended-length path prefix to bypass reserved name checks. + /// + /// true if the file was a reserved name and was deleted (or didn't exist). + public static bool TryDeleteReservedDeviceNameFile(string absolutePath) + { + if (!IsReservedDeviceName(absolutePath)) + return false; + + var extendedPath = @"\\?\" + absolutePath; + if (File.Exists(extendedPath)) + { + File.Delete(extendedPath); + } + + return true; + } } diff --git a/ForceOps.Lib/src/FileAndDirectoryDeleter.cs b/ForceOps.Lib/src/FileAndDirectoryDeleter.cs index 9015807..ba725bc 100644 --- a/ForceOps.Lib/src/FileAndDirectoryDeleter.cs +++ b/ForceOps.Lib/src/FileAndDirectoryDeleter.cs @@ -36,6 +36,10 @@ public void DeleteFileOrDirectory(string fileOrDirectory, bool force) DeleteDirectory(new DirectoryInfo(fileOrDirectory)); return; } + if (TryDeleteReservedDeviceNameFile(fileOrDirectory)) + { + return; + } if (!force) { @@ -46,6 +50,9 @@ public void DeleteFileOrDirectory(string fileOrDirectory, bool force) const int ERROR_ACCESS_DENIED = 5; internal void DeleteFile(FileInfo file) { + if (TryDeleteReservedDeviceNameFile(file.FullName)) + return; + for (var attempt = 1; attempt <= forceOpsContext.maxRetries + 1; attempt++) { try diff --git a/ForceOps.Test/src/FileAndDirectoryDeleterTest.cs b/ForceOps.Test/src/FileAndDirectoryDeleterTest.cs index 8384764..bae5bf3 100644 --- a/ForceOps.Test/src/FileAndDirectoryDeleterTest.cs +++ b/ForceOps.Test/src/FileAndDirectoryDeleterTest.cs @@ -87,6 +87,34 @@ public void DeletingReadonlyFileOpenByPowershell() Could not delete file .*. Beginning retry 1/10 in 50ms. ForceOps process is not elevated. Found 1 process to try to kill: \[\d+ \- powershell.exe\]", testContext.fakeLoggerFactory.GetAllLogsString()); } + [Fact] + public void DeletingFileWithReservedDeviceName() + { + var nulFilePath = Path.Combine(tempFolderPath, "nul"); + var extendedPath = @"\\?\" + nulFilePath; + + File.Create(extendedPath).Dispose(); + Assert.True(File.Exists(extendedPath)); + + fileAndDirectoryDeleter.DeleteFileOrDirectory(nulFilePath, false); + + Assert.False(File.Exists(extendedPath)); + } + + [Fact] + public void DeletingDirectoryContainingFileWithReservedDeviceName() + { + var nulFilePath = Path.Combine(tempFolderPath, "nul"); + var extendedPath = @"\\?\" + nulFilePath; + + File.Create(extendedPath).Dispose(); + Assert.True(File.Exists(extendedPath)); + + fileAndDirectoryDeleter.DeleteDirectory(new DirectoryInfo(tempFolderPath)); + + Assert.False(Directory.Exists(tempFolderPath)); + } + public ForceOpsMethodsTest() { tempFolderPath = GetTemporaryFileName(); diff --git a/global.json b/global.json index a02bac5..512142d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.200", + "version": "10.0.100", "rollForward": "latestFeature" } }