From 782d1cc5b17e3c4f9e4b4918b8bb8f05f1feaa33 Mon Sep 17 00:00:00 2001 From: Dom Slee Date: Mon, 3 Jul 2023 23:31:31 +1000 Subject: [PATCH] Releases/1.0.0 (#4) * Release 1.0.0 * Add linting * Refactor tests and improve logging messages --- .editorconfig | 2 + .github/workflows/ci.yaml | 3 + Directory.Build.props | 17 +++ ForceOps.Lib/ForceOps.Lib.csproj | 6 +- ForceOps.Lib/src/DirectoryUtils.cs | 2 +- ForceOps.Lib/src/FileAndDirectoryDeleter.cs | 129 ++++++++++++++++++ ForceOps.Lib/src/FileAndFolderDeleter.cs | 124 ----------------- ForceOps.Lib/src/ForceOpsContext.cs | 15 -- .../src/{ => ForceOpsContext}/ElevateUtils.cs | 7 +- .../src/ForceOpsContext/ForceOpsContext.cs | 23 ++++ .../src/ForceOpsContext/IElevateUtils.cs | 7 + .../src/ForceOpsContext/ILoggerFactory.cs | 8 ++ .../src/ForceOpsContext/IProcessKiller.cs | 6 + .../ForceOpsContext/IRelaunchAsElevated.cs | 6 + .../src/ForceOpsContext/LoggerFactory.cs | 15 ++ .../src/ForceOpsContext/ProcessKiller.cs | 31 +++++ .../ForceOpsContext}/RelaunchAsElevated.cs | 10 +- ForceOps.Lib/src/ForceOpsLoggerFactory.cs | 15 -- .../src/ProcessKiller/IProcessKiller.cs | 5 - .../src/ProcessKiller/ProcessKiller.cs | 21 --- ForceOps.Test/src/ForceOpsMethodsTest.cs | 51 ++++--- ForceOps.Test/src/ProgramTest.cs | 22 +-- ForceOps.Test/src/TestContext.cs | 40 ++++++ ForceOps.Test/src/TestUtil.cs | 47 ++++--- ForceOps/ForceOps.csproj | 14 -- ForceOps/src/Program.cs | 18 +-- README.md | 14 +- installAndUse.ps1 => installLocal.ps1 | 2 +- subrepos/Directory.Build.props | 3 + 29 files changed, 387 insertions(+), 276 deletions(-) create mode 100644 Directory.Build.props create mode 100644 ForceOps.Lib/src/FileAndDirectoryDeleter.cs delete mode 100644 ForceOps.Lib/src/FileAndFolderDeleter.cs delete mode 100644 ForceOps.Lib/src/ForceOpsContext.cs rename ForceOps.Lib/src/{ => ForceOpsContext}/ElevateUtils.cs (75%) create mode 100644 ForceOps.Lib/src/ForceOpsContext/ForceOpsContext.cs create mode 100644 ForceOps.Lib/src/ForceOpsContext/IElevateUtils.cs create mode 100644 ForceOps.Lib/src/ForceOpsContext/ILoggerFactory.cs create mode 100644 ForceOps.Lib/src/ForceOpsContext/IProcessKiller.cs create mode 100644 ForceOps.Lib/src/ForceOpsContext/IRelaunchAsElevated.cs create mode 100644 ForceOps.Lib/src/ForceOpsContext/LoggerFactory.cs create mode 100644 ForceOps.Lib/src/ForceOpsContext/ProcessKiller.cs rename {ForceOps/src => ForceOps.Lib/src/ForceOpsContext}/RelaunchAsElevated.cs (85%) delete mode 100644 ForceOps.Lib/src/ForceOpsLoggerFactory.cs delete mode 100644 ForceOps.Lib/src/ProcessKiller/IProcessKiller.cs delete mode 100644 ForceOps.Lib/src/ProcessKiller/ProcessKiller.cs create mode 100644 ForceOps.Test/src/TestContext.cs rename installAndUse.ps1 => installLocal.ps1 (91%) create mode 100644 subrepos/Directory.Build.props diff --git a/.editorconfig b/.editorconfig index b8f8c67..a3b38b5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,5 @@ csharp_hide_private_members = true:suggestion dotnet_diagnostic.CS0127.severity = none dotnet_diagnostic.CA1416.severity = none dotnet_diagnostic.IDE0005.severity = error +dotnet_diagnostic.IDE0044.severity = error +dotnet_sort_system_directives_first = true diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b87cb96..9248c61 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,5 +36,8 @@ jobs: - name: Build run: dotnet build + - name: Lint + run: dotnet format --verify-no-changes + - name: Test run: dotnet test --no-build --verbosity normal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..dd156ef --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + By hook or by crook, perform operations on files and directories. If they are + in use by a process, kill the process. + 1.0.0 + https://github.com/domsleee/forceops + https://github.com/domsleee/forceops + git + Dom Slee + lock file directory force delete + https://github.com/domsleee/forceops/blob/main/CHANGELOG.md + + README.md + LICENSE.txt + ./nupkg + + \ No newline at end of file diff --git a/ForceOps.Lib/ForceOps.Lib.csproj b/ForceOps.Lib/ForceOps.Lib.csproj index 38c8104..c71d877 100644 --- a/ForceOps.Lib/ForceOps.Lib.csproj +++ b/ForceOps.Lib/ForceOps.Lib.csproj @@ -4,8 +4,12 @@ net7.0 enable enable - ForceOps + + + + + diff --git a/ForceOps.Lib/src/DirectoryUtils.cs b/ForceOps.Lib/src/DirectoryUtils.cs index f9e2663..d42cdca 100644 --- a/ForceOps.Lib/src/DirectoryUtils.cs +++ b/ForceOps.Lib/src/DirectoryUtils.cs @@ -1,4 +1,4 @@ -namespace ForceOps; +namespace ForceOps.Lib; public static class DirectoryUtils { diff --git a/ForceOps.Lib/src/FileAndDirectoryDeleter.cs b/ForceOps.Lib/src/FileAndDirectoryDeleter.cs new file mode 100644 index 0000000..ad31f90 --- /dev/null +++ b/ForceOps.Lib/src/FileAndDirectoryDeleter.cs @@ -0,0 +1,129 @@ +using System.Runtime.Versioning; +using Serilog; +using static ForceOps.Lib.DirectoryUtils; +using static LockCheck.LockManager; + +namespace ForceOps.Lib; + +[SupportedOSPlatform("windows")] +public class FileAndDirectoryDeleter +{ + readonly ILogger logger; + readonly ForceOpsContext forceOpsContext; + + public FileAndDirectoryDeleter(ForceOpsContext forceOpsContext, ILogger? logger = null) + { + this.forceOpsContext = forceOpsContext; + this.logger = logger ?? forceOpsContext.loggerFactory.CreateLogger(); + } + + /// + /// Delete a file or a folder, not following symlinks. + /// If the delete fails, it will attempt to find processes using the file or directory + /// + /// File or directory to delete. + public void DeleteFileOrDirectory(string fileOrDirectory) + { + fileOrDirectory = CombineWithCWDAndGetAbsolutePath(fileOrDirectory); + if (File.Exists(fileOrDirectory)) + { + DeleteFile(new FileInfo(fileOrDirectory)); + return; + } + if (Directory.Exists(fileOrDirectory)) + { + DeleteDirectory(new DirectoryInfo(fileOrDirectory)); + return; + } + // if the file/folder doesn't exist, it has already been deleted + logger.Debug($"{fileOrDirectory} already deleted."); + return; + } + + internal void DeleteFile(FileInfo file) + { + for (var attempt = 1; attempt <= forceOpsContext.maxAttempts; attempt++) + { + try + { + file.IsReadOnly = false; + file.Delete(); + break; + } + catch when (!file.Exists) { } + catch (Exception ex) when (ex is IOException || ex is System.UnauthorizedAccessException) + { + var getProcessesLockingFileFunc = () => GetLockingProcessInfos(new[] { file.FullName }); + var shouldThrow = KillProcessesAndLogInfo("DeleteFile", attempt, file.FullName, getProcessesLockingFileFunc); + if (shouldThrow) throw; + } + } + } + + bool KillProcessesAndLogInfo(string actionName, int attemptNumber, string fileOrDirectoryPath, Func> getProcessesLockingFileFunc) + { + var isProcessElevated = forceOpsContext.elevateUtils.IsProcessElevated(); + var processElevatedMessage = isProcessElevated + ? "ForceOps process is elevated" + : "ForceOps process is not elevated"; + var messagePrefix = $"{actionName} failed attempt {attemptNumber}/{forceOpsContext.maxAttempts} for [{fileOrDirectoryPath}]. {processElevatedMessage}."; + + if (attemptNumber == forceOpsContext.maxAttempts) + { + logger.Information($"{messagePrefix} No attempts remain, so the exception will be thrown."); + return true; + } + + var processes = getProcessesLockingFileFunc().ToList(); + var processPlural = processes.Count == 1 ? "process" : "processes"; + var processLogString = string.Join(", ", processes.Select(process => ProcessInfoToString(process))); + logger.Information($"{messagePrefix} Found {processes.Count} {processPlural} to try to kill: [{processLogString}]"); + forceOpsContext.processKiller.KillProcesses(processes); + return false; + } + + static string ProcessInfoToString(LockCheck.ProcessInfo? process) + { + if (process == null) + { + return ""; + } + return $"{process?.ProcessId} - {process?.ExecutableName}"; + } + + internal void DeleteDirectory(DirectoryInfo directory) + { + if (!IsSymLink(directory)) + { + DeleteFilesInFolder(directory); + } + + for (var attempt = 1; attempt <= forceOpsContext.maxAttempts; attempt++) + { + try + { + directory.Delete(); + break; + } + catch when (!directory.Exists) { } + catch (Exception ex) when (ex is IOException) + { + var getProcessesLockingFileFunc = () => GetLockingProcessInfos(new[] { directory.FullName }, LockCheck.LockManagerFeatures.UseLowLevelApi); + var shouldThrow = KillProcessesAndLogInfo("DeleteDirectory", attempt, directory.FullName, getProcessesLockingFileFunc); + if (shouldThrow) throw; + } + } + } + + void DeleteFilesInFolder(DirectoryInfo directory) + { + foreach (var file in directory.GetFiles()) + { + DeleteFile(file); + } + foreach (var subDirectory in directory.GetDirectories()) + { + DeleteDirectory(subDirectory); + } + } +} \ No newline at end of file diff --git a/ForceOps.Lib/src/FileAndFolderDeleter.cs b/ForceOps.Lib/src/FileAndFolderDeleter.cs deleted file mode 100644 index f4dc1cd..0000000 --- a/ForceOps.Lib/src/FileAndFolderDeleter.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Serilog; -using System.Runtime.Versioning; -using static ForceOps.DirectoryUtils; - -namespace ForceOps; - -[SupportedOSPlatform("windows")] -public class FileAndFolderDeleter -{ - readonly ILogger logger; - readonly ForceOpsContext forceOpsContext; - - public FileAndFolderDeleter(ForceOpsContext forceOpsContext, ILogger? logger = null) - { - this.forceOpsContext = forceOpsContext; - this.logger = logger ?? ForceOpsLoggerFactory.CreateLogger(); - } - - /// Delete a file or a folder, not following symlinks. - /// If the delete fails, it will attempt to find processes using the file or directory - public void DeleteFileOrDirectory(string fileOrDirectory) - { - fileOrDirectory = CombineWithCWDAndGetAbsolutePath(fileOrDirectory); - if (File.Exists(fileOrDirectory)) - { - DeleteFile(new FileInfo(fileOrDirectory)); - return; - } - if (Directory.Exists(fileOrDirectory)) - { - DeleteDirectory(new DirectoryInfo(fileOrDirectory)); - return; - } - // if the file/folder doesn't exist, it has already been deleted - logger.Debug($"{fileOrDirectory} deleted."); - return; - } - - internal void DeleteFile(FileInfo file) - { - var retries = forceOpsContext.maxRetries; - do - { - try - { - file.IsReadOnly = false; - file.Delete(); - break; - } - catch when (!file.Exists) { } - catch (Exception ex) when ((ex is IOException || ex is System.UnauthorizedAccessException) && retries > 0) - { - var processes = FilterNullProcesses(LockCheck.LockManager.GetLockingProcessInfos(new[] { file.FullName })); - logger.Information($"DeleteFolder found these processes: {GetProcessLogString(processes)}"); - forceOpsContext.processKiller!.KillProcesses(processes); - } - } while (retries-- > 0); - } - - internal void DeleteDirectory(DirectoryInfo directory) - { - if (!IsSymLink(directory)) - { - DeleteFilesInFolder(directory); - } - - var retries = forceOpsContext.maxRetries; - do - { - try - { - directory.Delete(); - break; - } - catch when (!directory.Exists) { } - catch (Exception ex) when (ex is IOException && retries > 0) - { - var processes = FilterNullProcesses(LockCheck.LockManager.GetLockingProcessInfos(new[] { directory.FullName }, LockCheck.LockManagerFeatures.UseLowLevelApi)); - logger.Information($"DeleteFolder found these processes: {GetProcessLogString(processes)}"); - forceOpsContext.processKiller.KillProcesses(processes); - } - } while (retries-- > 0); - - } - - void DeleteFilesInFolder(DirectoryInfo directory) - { - foreach (var file in directory.GetFiles()) - { - DeleteFile(file); - } - foreach (var subDirectory in directory.GetDirectories()) - { - DeleteDirectory(subDirectory); - } - } - - IEnumerable FilterNullProcesses(IEnumerable processInfos) - { - foreach (var processInfo in processInfos) - { - if (processInfo == null) - { - logger.Error($"LockCheck API returned a null process. {GetAdminLogMessage()}"); - continue; - } - yield return processInfo; - } - } - - string GetAdminLogMessage() - { - if (!forceOpsContext.elevateUtils.IsProcessElevated()) - { - return "Process is NOT elevated"; - } - return "Process is elevated"; - } - - string GetProcessLogString(IEnumerable processes) - { - return $"[{string.Join(", ", processes.Select(process => $"{process?.ProcessId} - {process?.ExecutableName}"))}]"; - } -} \ No newline at end of file diff --git a/ForceOps.Lib/src/ForceOpsContext.cs b/ForceOps.Lib/src/ForceOpsContext.cs deleted file mode 100644 index ada79d0..0000000 --- a/ForceOps.Lib/src/ForceOpsContext.cs +++ /dev/null @@ -1,15 +0,0 @@ -using ForceOpsLib; - -namespace ForceOps; -public class ForceOpsContext -{ - public int maxRetries = 3; - public IProcessKiller processKiller; - public IElevateUtils elevateUtils; - - public ForceOpsContext(IProcessKiller? processKiller = null, IElevateUtils? elevateUtils = null) - { - this.processKiller = processKiller ?? new ProcessKiller(); - this.elevateUtils = elevateUtils ?? new ElevateUtils(); - } -} diff --git a/ForceOps.Lib/src/ElevateUtils.cs b/ForceOps.Lib/src/ForceOpsContext/ElevateUtils.cs similarity index 75% rename from ForceOps.Lib/src/ElevateUtils.cs rename to ForceOps.Lib/src/ForceOpsContext/ElevateUtils.cs index 885dcec..059a116 100644 --- a/ForceOps.Lib/src/ElevateUtils.cs +++ b/ForceOps.Lib/src/ForceOpsContext/ElevateUtils.cs @@ -1,6 +1,6 @@ using System.Security.Principal; -namespace ForceOpsLib; +namespace ForceOps.Lib; public class ElevateUtils : IElevateUtils { @@ -10,9 +10,4 @@ public bool IsProcessElevated() var principal = new WindowsPrincipal(identity); return principal.IsInRole(WindowsBuiltInRole.Administrator); } -} - -public interface IElevateUtils -{ - public bool IsProcessElevated(); } \ No newline at end of file diff --git a/ForceOps.Lib/src/ForceOpsContext/ForceOpsContext.cs b/ForceOps.Lib/src/ForceOpsContext/ForceOpsContext.cs new file mode 100644 index 0000000..dd33eea --- /dev/null +++ b/ForceOps.Lib/src/ForceOpsContext/ForceOpsContext.cs @@ -0,0 +1,23 @@ +namespace ForceOps.Lib; + +public class ForceOpsContext +{ + public int maxAttempts = 5; + public IProcessKiller processKiller; + public IElevateUtils elevateUtils; + public IRelaunchAsElevated relaunchAsElevated; + public ILoggerFactory loggerFactory; + + public ForceOpsContext( + IProcessKiller? processKiller = null, + IElevateUtils? elevateUtils = null, + IRelaunchAsElevated? relaunchAsElevated = null, + ILoggerFactory? loggerFactory = null + ) + { + this.processKiller = processKiller ?? new ProcessKiller(); + this.elevateUtils = elevateUtils ?? new ElevateUtils(); + this.relaunchAsElevated = relaunchAsElevated ?? new RelaunchAsElevated(); + this.loggerFactory = loggerFactory ?? new LoggerFactory(); + } +} diff --git a/ForceOps.Lib/src/ForceOpsContext/IElevateUtils.cs b/ForceOps.Lib/src/ForceOpsContext/IElevateUtils.cs new file mode 100644 index 0000000..29b26f7 --- /dev/null +++ b/ForceOps.Lib/src/ForceOpsContext/IElevateUtils.cs @@ -0,0 +1,7 @@ +namespace ForceOps.Lib; + + +public interface IElevateUtils +{ + public bool IsProcessElevated(); +} \ No newline at end of file diff --git a/ForceOps.Lib/src/ForceOpsContext/ILoggerFactory.cs b/ForceOps.Lib/src/ForceOpsContext/ILoggerFactory.cs new file mode 100644 index 0000000..81d4e72 --- /dev/null +++ b/ForceOps.Lib/src/ForceOpsContext/ILoggerFactory.cs @@ -0,0 +1,8 @@ +using Serilog; + +namespace ForceOps.Lib; + +public interface ILoggerFactory +{ + public ILogger CreateLogger(); +} \ No newline at end of file diff --git a/ForceOps.Lib/src/ForceOpsContext/IProcessKiller.cs b/ForceOps.Lib/src/ForceOpsContext/IProcessKiller.cs new file mode 100644 index 0000000..9f37e66 --- /dev/null +++ b/ForceOps.Lib/src/ForceOpsContext/IProcessKiller.cs @@ -0,0 +1,6 @@ +namespace ForceOps; + +public interface IProcessKiller +{ + void KillProcesses(IEnumerable processes); +} diff --git a/ForceOps.Lib/src/ForceOpsContext/IRelaunchAsElevated.cs b/ForceOps.Lib/src/ForceOpsContext/IRelaunchAsElevated.cs new file mode 100644 index 0000000..e1305d4 --- /dev/null +++ b/ForceOps.Lib/src/ForceOpsContext/IRelaunchAsElevated.cs @@ -0,0 +1,6 @@ +namespace ForceOps.Lib; + +public interface IRelaunchAsElevated +{ + int RelaunchAsElevated(); +} diff --git a/ForceOps.Lib/src/ForceOpsContext/LoggerFactory.cs b/ForceOps.Lib/src/ForceOpsContext/LoggerFactory.cs new file mode 100644 index 0000000..51fc4cf --- /dev/null +++ b/ForceOps.Lib/src/ForceOpsContext/LoggerFactory.cs @@ -0,0 +1,15 @@ +using Serilog; + +namespace ForceOps.Lib; + +public class LoggerFactory : ILoggerFactory +{ + readonly ILogger logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateLogger(); + + public ILogger CreateLogger() + { + return logger; + } +} \ No newline at end of file diff --git a/ForceOps.Lib/src/ForceOpsContext/ProcessKiller.cs b/ForceOps.Lib/src/ForceOpsContext/ProcessKiller.cs new file mode 100644 index 0000000..d43406a --- /dev/null +++ b/ForceOps.Lib/src/ForceOpsContext/ProcessKiller.cs @@ -0,0 +1,31 @@ +using System.Diagnostics; +using LockCheck; + +namespace ForceOps.Lib; + +internal class ProcessKiller : IProcessKiller +{ + public void KillProcesses(IEnumerable processes) + { + var runningProcesses = new List(); + foreach (var process in processes) + { + if (process == null) continue; + try + { + runningProcesses.Add(Process.GetProcessById(process.ProcessId)); + } + catch (ArgumentException) { } // If the process is no longer running + } + + foreach (var process in runningProcesses) + { + process.Kill(); + } + + foreach (var process in runningProcesses) + { + process.WaitForExit(); + } + } +} diff --git a/ForceOps/src/RelaunchAsElevated.cs b/ForceOps.Lib/src/ForceOpsContext/RelaunchAsElevated.cs similarity index 85% rename from ForceOps/src/RelaunchAsElevated.cs rename to ForceOps.Lib/src/ForceOpsContext/RelaunchAsElevated.cs index cbb38ae..89b7298 100644 --- a/ForceOps/src/RelaunchAsElevated.cs +++ b/ForceOps.Lib/src/ForceOpsContext/RelaunchAsElevated.cs @@ -1,7 +1,8 @@ using System.Diagnostics; -namespace ForceOps.src; -internal class RelaunchAsElevated : IRelaunchAsElevated +namespace ForceOps.Lib; + +public class RelaunchAsElevated : IRelaunchAsElevated { int IRelaunchAsElevated.RelaunchAsElevated() { @@ -33,8 +34,3 @@ int IRelaunchAsElevated.RelaunchAsElevated() return process.ExitCode; } } - -internal interface IRelaunchAsElevated -{ - int RelaunchAsElevated(); -} diff --git a/ForceOps.Lib/src/ForceOpsLoggerFactory.cs b/ForceOps.Lib/src/ForceOpsLoggerFactory.cs deleted file mode 100644 index 0fdd244..0000000 --- a/ForceOps.Lib/src/ForceOpsLoggerFactory.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Serilog; - -namespace ForceOps; - -public class ForceOpsLoggerFactory -{ - static ILogger logger = new LoggerConfiguration() - .WriteTo.Console() - .CreateLogger(); - - public static ILogger CreateLogger() - { - return logger; - } -} diff --git a/ForceOps.Lib/src/ProcessKiller/IProcessKiller.cs b/ForceOps.Lib/src/ProcessKiller/IProcessKiller.cs deleted file mode 100644 index 1874f2f..0000000 --- a/ForceOps.Lib/src/ProcessKiller/IProcessKiller.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ForceOps; -public interface IProcessKiller -{ - void KillProcesses(IEnumerable processes); -} diff --git a/ForceOps.Lib/src/ProcessKiller/ProcessKiller.cs b/ForceOps.Lib/src/ProcessKiller/ProcessKiller.cs deleted file mode 100644 index 09d40a4..0000000 --- a/ForceOps.Lib/src/ProcessKiller/ProcessKiller.cs +++ /dev/null @@ -1,21 +0,0 @@ -using LockCheck; -using System.Diagnostics; - -namespace ForceOps; -internal class ProcessKiller : IProcessKiller -{ - public void KillProcesses(IEnumerable processes) - { - var runningProcesses = processes.Select(process => Process.GetProcessById(process.ProcessId)).Where(process => process != null).ToList(); - - foreach (var process in runningProcesses) - { - process.Kill(); - } - - foreach (var process in runningProcesses) - { - process.WaitForExit(); - } - } -} diff --git a/ForceOps.Test/src/ForceOpsMethodsTest.cs b/ForceOps.Test/src/ForceOpsMethodsTest.cs index 2f720f9..1ff50ff 100644 --- a/ForceOps.Test/src/ForceOpsMethodsTest.cs +++ b/ForceOps.Test/src/ForceOpsMethodsTest.cs @@ -1,47 +1,60 @@ +using ForceOps.Lib; using static ForceOps.Test.TestUtil; namespace ForceOps.Test; public class ForceOpsMethodsTest : IDisposable { - List disposables = new List(); - ForceOpsContext forceOpsContext; - FileAndFolderDeleter fileAndFolderDeleter; - string tempFolderPath; + readonly List disposables = new(); + readonly ForceOpsContext forceOpsContext; + readonly FileAndDirectoryDeleter fileAndDirectoryDeleter; + readonly string tempFolderPath; + readonly TestContext testContext; [Fact] - public void DeletingDirectoryOpenInCMDWindow() + public void DeletingDirectoryOpenInPowershellWorkingDirectory() { - using var launchedProcess = LaunchCMDInDirectory(tempFolderPath); + using var launchedProcess = LaunchProcessInDirectory(tempFolderPath); - forceOpsContext.maxRetries = 0; - var exceptionWithNoRetries = Record.Exception(() => fileAndFolderDeleter.DeleteDirectory(new DirectoryInfo(tempFolderPath))); + forceOpsContext.maxAttempts = 1; + var exceptionWithNoRetries = Record.Exception(() => fileAndDirectoryDeleter.DeleteDirectory(new DirectoryInfo(tempFolderPath))); Assert.IsType(exceptionWithNoRetries); Assert.StartsWith("The process cannot access the file", exceptionWithNoRetries.Message); - forceOpsContext.maxRetries = 3; - var exceptionWithDirectoryStrategy = Record.Exception(() => fileAndFolderDeleter.DeleteDirectory(new DirectoryInfo(tempFolderPath))); - Assert.Null(exceptionWithDirectoryStrategy); + + forceOpsContext.maxAttempts = 5; + var exceptionWithDirectoryStrategy = Record.Exception(() => fileAndDirectoryDeleter.DeleteDirectory(new DirectoryInfo(tempFolderPath))); + Assert.True(null == exceptionWithDirectoryStrategy, testContext.fakeLoggerFactory.GetAllLogsString()); + + Assert.Matches(@"DeleteDirectory failed attempt 1/1 for \[.*\]. ForceOps process is not elevated. No attempts remain, so the exception will be thrown. +DeleteDirectory failed attempt 1/5 for \[.*\]. ForceOps process is not elevated. Found 1 process to try to kill: \[\d+ \- powershell.exe\]", testContext.fakeLoggerFactory.GetAllLogsString()); } [Fact] - public void DeletingFile() + public void DeletingFileOpenByPowershell() { - using var launchedProcess = LaunchCMDInDirectory(tempFolderPath); var tempFilePath = GetTemporaryFileName(); - File.Open(tempFilePath, FileMode.OpenOrCreate); + using var launchedProcess = HoldLockOnFileUsingPowershell(tempFilePath); - forceOpsContext.maxRetries = 0; - var exceptionWithNoRetries = Record.Exception(() => fileAndFolderDeleter.DeleteFile(new FileInfo(tempFilePath))); + forceOpsContext.maxAttempts = 1; + var exceptionWithNoRetries = Record.Exception(() => fileAndDirectoryDeleter.DeleteFile(new FileInfo(tempFilePath))); Assert.IsType(exceptionWithNoRetries); - var ioException = exceptionWithNoRetries as IOException; + Assert.StartsWith("The process cannot access the file", exceptionWithNoRetries.Message); + + forceOpsContext.maxAttempts = 5; + var exceptionWithDirectoryStrategy = Record.Exception(() => fileAndDirectoryDeleter.DeleteFile(new FileInfo(tempFilePath))); + Assert.True(null == exceptionWithDirectoryStrategy, testContext.fakeLoggerFactory.GetAllLogsString()); + + Assert.Matches($@"DeleteFile failed attempt 1/1 for \[.*\]. ForceOps process is not elevated. No attempts remain, so the exception will be thrown. +DeleteFile failed attempt 1/5 for \[.*\]. ForceOps process is not elevated. Found 1 process to try to kill: \[\d+ \- powershell.exe\]", testContext.fakeLoggerFactory.GetAllLogsString()); } public ForceOpsMethodsTest() { tempFolderPath = GetTemporaryFileName(); disposables.Add(CreateTemporaryDirectory(tempFolderPath)); - forceOpsContext = SetupTestContext(); - fileAndFolderDeleter = new FileAndFolderDeleter(forceOpsContext); + testContext = new TestContext(); + forceOpsContext = testContext.forceOpsContext; + fileAndDirectoryDeleter = new FileAndDirectoryDeleter(forceOpsContext); } void IDisposable.Dispose() diff --git a/ForceOps.Test/src/ProgramTest.cs b/ForceOps.Test/src/ProgramTest.cs index b58ff54..5a472d2 100644 --- a/ForceOps.Test/src/ProgramTest.cs +++ b/ForceOps.Test/src/ProgramTest.cs @@ -1,24 +1,24 @@ -using ForceOps.src; -using Moq; +using Moq; using static ForceOps.Test.TestUtil; namespace ForceOps.Test; -public class ProgramTest : IDisposable + +public sealed class ProgramTest : IDisposable { - List disposables = new List(); - string tempDirectoryPath; + readonly List disposables = new(); + readonly string tempDirectoryPath; [Fact] public void ExceptionsBubble() { - using var launchedProcess = LaunchCMDInDirectory(tempDirectoryPath); - var launchAsElevatedMock = new Mock(); - Program.relaunchAsElevated = launchAsElevatedMock.Object; - Program.forceOpsContext = SetupTestContext(); - Program.forceOpsContext.maxRetries = 0; + using var launchedProcess = LaunchProcessInDirectory(tempDirectoryPath); + var testContext = new TestContext(); + Program.forceOpsContext = testContext.forceOpsContext; + Program.forceOpsContext.maxAttempts = 1; var exceptionWithNoRetries = Record.Exception(() => Program.DeleteCommand(new[] { tempDirectoryPath })); + Assert.IsType(exceptionWithNoRetries); - launchAsElevatedMock.Verify(t => t.RelaunchAsElevated(), Times.Once()); + testContext.relaunchAsElevatedMock.Verify(t => t.RelaunchAsElevated(), Times.Once()); } public ProgramTest() diff --git a/ForceOps.Test/src/TestContext.cs b/ForceOps.Test/src/TestContext.cs new file mode 100644 index 0000000..fad3225 --- /dev/null +++ b/ForceOps.Test/src/TestContext.cs @@ -0,0 +1,40 @@ +using ForceOps.Lib; +using Moq; +using Serilog; + +namespace ForceOps.Test; + +public class TestContext +{ + public ForceOpsContext forceOpsContext; + public Mock elevateUtilsMock; + public Mock relaunchAsElevatedMock; + public FakeLoggerFactory fakeLoggerFactory; + + public TestContext() + { + elevateUtilsMock = new Mock(); + elevateUtilsMock.Setup(t => t.IsProcessElevated()).Returns(false); + relaunchAsElevatedMock = new Mock(); + fakeLoggerFactory = new FakeLoggerFactory(); + + forceOpsContext = new ForceOpsContext(elevateUtils: elevateUtilsMock.Object, loggerFactory: fakeLoggerFactory, relaunchAsElevated: relaunchAsElevatedMock.Object); + } +} + +public class FakeLoggerFactory : ILoggerFactory +{ + public readonly List Logs = new(); + + public ILogger CreateLogger() + { + var logger = new Mock(); + logger.Setup(t => t.Information(It.IsAny())).Callback(line => { Logs.Add(line); }); + return logger.Object; + } + + public string GetAllLogsString() + { + return string.Join(Environment.NewLine, Logs); + } +} diff --git a/ForceOps.Test/src/TestUtil.cs b/ForceOps.Test/src/TestUtil.cs index 5f265ff..4a078df 100644 --- a/ForceOps.Test/src/TestUtil.cs +++ b/ForceOps.Test/src/TestUtil.cs @@ -1,12 +1,21 @@ -using ForceOpsLib; -using Moq; -using System.Diagnostics; +using System.Diagnostics; using System.Reactive.Disposables; namespace ForceOps.Test; + public static class TestUtil { - public static IDisposable LaunchCMDInDirectory(string workingDirectory) + public static IDisposable LaunchProcessInDirectory(string workingDirectory) + { + return LaunchPowershellWithCommand(workingDirectory: workingDirectory); + } + + public static IDisposable HoldLockOnFileUsingPowershell(string filePath) + { + return LaunchPowershellWithCommand(command: $"[System.IO.File]::Open('{filePath}', 'OpenOrCreate')"); + } + + public static IDisposable LaunchPowershellWithCommand(string command = "", string workingDirectory = "") { var process = new Process { @@ -14,39 +23,42 @@ public static IDisposable LaunchCMDInDirectory(string workingDirectory) { FileName = "powershell", WorkingDirectory = workingDirectory, - Arguments = "-Command \"echo 'loaded'; sleep 10\"", + Arguments = $"-NoProfile -Command \"{command}; echo 'process has been loaded'; sleep 10\"", RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true } }; - string output = ""; + + List output = new(); + List error = new(); process.OutputDataReceived += (sender, e) => { - output += e.Data; + if (e.Data != null) output.Add(e.Data); }; - string error = ""; process.ErrorDataReceived += (sender, e) => { - error += e.Data; + if (e.Data != null) error.Add(e.Data); }; + process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); var startTime = DateTime.Now; - while (!output.StartsWith("loaded") && !process.HasExited) + while (!(output.LastOrDefault() ?? "").EndsWith("process has been loaded") && !process.HasExited) { Thread.Sleep(50); - if (DateTime.Now.Subtract(startTime).TotalSeconds > 2) { + if (DateTime.Now.Subtract(startTime).TotalSeconds > 2) + { throw new Exception("Gave up after waiting 2 seconds"); } } - + if (process.HasExited) { - throw new Exception($"Process has exited unexpectedly.\nOutput: {output}\nError: {error}"); + throw new Exception($"Process has exited unexpectedly.\nOutput: {string.Join("\n", output)}\nError: {string.Join("\n", error)}"); } return Disposable.Create(() => @@ -73,13 +85,4 @@ public static IDisposable CreateTemporaryDirectory(string directory) catch { } }); } - - public static ForceOpsContext SetupTestContext() - { - var context = new ForceOpsContext(); - var elevateUtilsMock = new Mock(); - elevateUtilsMock.Setup(t => t.IsProcessElevated()).Returns(false); - context.elevateUtils = elevateUtilsMock.Object; - return context; - } } diff --git a/ForceOps/ForceOps.csproj b/ForceOps/ForceOps.csproj index cb14446..eb2a5d5 100644 --- a/ForceOps/ForceOps.csproj +++ b/ForceOps/ForceOps.csproj @@ -8,26 +8,12 @@ LatestMajor latest true - 1.0.0-pre - By hook or by crook, perform operations on files and directories. If they are - in use by a process, kill the process. - https://github.com/domsleee/forceops - https://github.com/domsleee/forceops - git - Dom Slee - lock file directory force delete - https://github.com/domsleee/forceops/blob/main/CHANGELOG.md - - README.md - LICENSE.txt - true forceops - ./nupkg diff --git a/ForceOps/src/Program.cs b/ForceOps/src/Program.cs index 1751138..89d9e31 100644 --- a/ForceOps/src/Program.cs +++ b/ForceOps/src/Program.cs @@ -1,16 +1,15 @@ -using ForceOps.src; -using Serilog; -using System.CommandLine; +using System.CommandLine; using System.Runtime.Versioning; +using ForceOps.Lib; +using Serilog; namespace ForceOps; [SupportedOSPlatform("windows")] public class Program { - static ILogger logger = ForceOpsLoggerFactory.CreateLogger(); - internal static IRelaunchAsElevated relaunchAsElevated = new RelaunchAsElevated(); - internal static ForceOpsContext forceOpsContext = new ForceOpsContext(); + internal static ForceOpsContext forceOpsContext = new(); + static readonly ILogger logger = forceOpsContext.loggerFactory.CreateLogger(); internal static bool CanRelaunchAsElevated = true; static int Main(string[] args) @@ -35,7 +34,8 @@ internal static void DeleteCommand(string[] filesOrDirectoriesToDelete) { RunWithRelaunchAsElevated(() => { - var deleter = new FileAndFolderDeleter(forceOpsContext); + var deleter = new FileAndDirectoryDeleter(forceOpsContext); + filesOrDirectoriesToDelete = filesOrDirectoriesToDelete.Select(file => DirectoryUtils.CombineWithCWDAndGetAbsolutePath(file)).ToArray(); foreach (var file in filesOrDirectoriesToDelete) { deleter.DeleteFileOrDirectory(file); @@ -52,12 +52,12 @@ static void RunWithRelaunchAsElevated(Action action) catch (Exception ex) when ((ex is IOException || ex is UnauthorizedAccessException) && !forceOpsContext.elevateUtils.IsProcessElevated()) { logger.Information("Received IOException or UnauthorizedAccessException when trying to get process using file or directory. Retrying as elevated."); - var childProcessExitCode = relaunchAsElevated.RelaunchAsElevated(); + var childProcessExitCode = forceOpsContext.relaunchAsElevated.RelaunchAsElevated(); var childResultMessage = childProcessExitCode == 0 ? "Successfully deleted as admin" : $"Failed with exit code {childProcessExitCode}"; logger.Information(childResultMessage); - throw new AggregateException($"Child process failed with {childProcessExitCode}. See inner exception for local exception.", ex); + throw new AggregateException($"Child process failed with {childProcessExitCode}. See inner exception for the previous exception.", ex); } } } \ No newline at end of file diff --git a/README.md b/README.md index 23b9630..c9a2bbc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# forceops +# ForceOps [![main build](https://github.com/domsleee/forceops/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/domsleee/forceops/actions/workflows/ci.yaml) -![Nuget](https://img.shields.io/nuget/dt/ForceOps) +![Nuget](https://img.shields.io/nuget/v/ForceOps) Forcefully perform file operations by terminating processes that are using the file. @@ -15,14 +15,18 @@ Currently, only `delete` is supported. Operations like `move` and `copy` can have similar issues if they are overriding files or the source file is in use. It would be reasonable to support these operations in a similar way. -## Usage +## Usage: As a CLI To install: ```bash -dotnet tool install -g --prerelease forceops +dotnet tool install -g forceops ``` To delete a file: ```bash forceops delete file.txt -``` \ No newline at end of file +``` + +## Usage: As a library + +See the [ForceOps.Lib](https://www.nuget.org/packages/ForceOps.Lib) package. \ No newline at end of file diff --git a/installAndUse.ps1 b/installLocal.ps1 similarity index 91% rename from installAndUse.ps1 rename to installLocal.ps1 index 7825781..72506a0 100644 --- a/installAndUse.ps1 +++ b/installLocal.ps1 @@ -5,5 +5,5 @@ pwsh -NoProfile -Command { { dotnet tool uninstall -g ForceOps } - dotnet tool install -g ForceOps --prerelease --add-source "$(pwd)/nupkg" + dotnet tool install -g ForceOps --prerelease --add-source "$(pwd)/nupkg" --no-cache } \ No newline at end of file diff --git a/subrepos/Directory.Build.props b/subrepos/Directory.Build.props new file mode 100644 index 0000000..01f63f4 --- /dev/null +++ b/subrepos/Directory.Build.props @@ -0,0 +1,3 @@ + + + \ No newline at end of file