diff --git a/Cyberduck.sln b/Cyberduck.sln
index 07d25e7a0d..432b0e0c6a 100644
--- a/Cyberduck.sln
+++ b/Cyberduck.sln
@@ -1,6 +1,6 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
-VisualStudioVersion = 18.2.11415.280 d18.0
+VisualStudioVersion = 18.2.11415.280
MinimumVisualStudioVersion = 15.0.26124.0
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{8B08EF96-10D6-4F35-94C1-986F9F0F1506}"
EndProject
@@ -61,6 +61,8 @@ Project("{DAEA77DE-8320-43BA-BA7C-EF5C12478AB5}") = "Cyberduck.Cryptomator", "cr
EndProject
Project("{DAEA77DE-8320-43BA-BA7C-EF5C12478AB5}") = "Cyberduck.Cli", "cli\dll\Cyberduck.Cli.ikvmproj", "{2D33598A-21A1-4117-82DC-250F4CE8D5E5}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "test-utils", "windows\src\test\utils\test-utils.csproj", "{BBCC0F0C-0135-AAAB-DB32-CC4E28BC88E0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -141,6 +143,10 @@ Global
{2D33598A-21A1-4117-82DC-250F4CE8D5E5}.Debug|x64.Build.0 = Debug|Any CPU
{2D33598A-21A1-4117-82DC-250F4CE8D5E5}.Release|x64.ActiveCfg = Release|Any CPU
{2D33598A-21A1-4117-82DC-250F4CE8D5E5}.Release|x64.Build.0 = Release|Any CPU
+ {BBCC0F0C-0135-AAAB-DB32-CC4E28BC88E0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BBCC0F0C-0135-AAAB-DB32-CC4E28BC88E0}.Debug|x64.Build.0 = Debug|Any CPU
+ {BBCC0F0C-0135-AAAB-DB32-CC4E28BC88E0}.Release|x64.ActiveCfg = Release|Any CPU
+ {BBCC0F0C-0135-AAAB-DB32-CC4E28BC88E0}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -168,6 +174,7 @@ Global
{9C7B827F-AE30-44C4-A210-E49DF883C720} = {72B4BA09-65D8-4C49-930E-B14104B2AB1B}
{7EFC0398-8F4D-4850-BBE3-A0CC85410559} = {72B4BA09-65D8-4C49-930E-B14104B2AB1B}
{2D33598A-21A1-4117-82DC-250F4CE8D5E5} = {72B4BA09-65D8-4C49-930E-B14104B2AB1B}
+ {BBCC0F0C-0135-AAAB-DB32-CC4E28BC88E0} = {8B08EF96-10D6-4F35-94C1-986F9F0F1506}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {289E6003-15D5-4377-ADA6-2E7093785BCD}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a53498443a..d16e1d5ab6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -39,6 +39,7 @@
+
diff --git a/windows/src/test/utils/CredDeleteCommand.cs b/windows/src/test/utils/CredDeleteCommand.cs
new file mode 100644
index 0000000000..ed61ccfefe
--- /dev/null
+++ b/windows/src/test/utils/CredDeleteCommand.cs
@@ -0,0 +1,62 @@
+using System;
+using System.CommandLine;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+using Windows.Win32;
+using Windows.Win32.Security.Credentials;
+using static Program;
+
+namespace test_utils;
+
+internal static class CredDeleteCommand
+{
+ internal static unsafe void Invoke(ParseResult result)
+ {
+ var patternValues = result.GetValue(Program.CredMatch);
+ Regex[] patterns = null;
+ if (patternValues is not null)
+ {
+ patterns = new Regex[patternValues.Length];
+ var patternWriter = ((Span)patterns).GetEnumerator();
+ foreach (var item in patternValues)
+ {
+ patternWriter.MoveNext();
+ patternWriter.Current = new Regex(item, RegexOptions.Compiled);
+ }
+ }
+
+ CREDENTIALW** credentials = null;
+ try
+ {
+ if (!PInvoke.CredEnumerate(null, out var count, out credentials))
+ {
+ throw Marshal.GetExceptionForHR(Marshal.GetHRForLastWin32Error());
+ }
+
+ foreach (ref readonly var credential in new ReadOnlySpan(credentials, (int)count))
+ {
+ bool? matched;
+ if ((matched = patterns?.Any(credential.Value.TargetName.ToString().Match)) is false)
+ {
+ continue;
+ }
+
+ Console.WriteLine($"{credential.Value.TargetName} ({credential.Value.UserName})");
+ if (matched is true)
+ {
+ if (!PInvoke.CredDelete(credential.Value.TargetName, credential.Value.Type, 0))
+ {
+ Console.WriteLine($" Failure deleting: {Marshal.GetLastPInvokeErrorMessage()}");
+ }
+ }
+ }
+ }
+ finally
+ {
+ PInvoke.CredFree(credentials);
+ }
+ }
+
+ private static bool Match(this string text, Regex pattern) => pattern.IsMatch(text);
+}
diff --git a/windows/src/test/utils/NativeMethods.txt b/windows/src/test/utils/NativeMethods.txt
new file mode 100644
index 0000000000..557f60df4f
--- /dev/null
+++ b/windows/src/test/utils/NativeMethods.txt
@@ -0,0 +1,3 @@
+CredDelete
+CredEnumerate
+CredFree
diff --git a/windows/src/test/utils/Program.cs b/windows/src/test/utils/Program.cs
new file mode 100644
index 0000000000..d41b304302
--- /dev/null
+++ b/windows/src/test/utils/Program.cs
@@ -0,0 +1,30 @@
+using System.CommandLine;
+using test_utils;
+using Windows.Win32.Security.Credentials;
+
+RootCommand command = [];
+
+Command credDeleteCommand = new("cred-delete", "Lists Credentials, and optionally deletes them when matching.");
+command.Add(credDeleteCommand);
+credDeleteCommand.SetAction(CredDeleteCommand.Invoke);
+credDeleteCommand.Add(CredMatch = new("--match")
+{
+ AllowMultipleArgumentsPerToken = true,
+ Arity = ArgumentArity.ZeroOrMore,
+ Description = "When specified, Cred-Delete will delete items matching any of the Regex patterns."
+});
+
+
+return command.Parse(args).Invoke();
+
+partial class Program
+{
+ internal static Option CredMatch;
+
+ internal readonly unsafe struct PCREDENTIALW
+ {
+ private readonly CREDENTIALW* _ptr;
+
+ public readonly ref CREDENTIALW Value => ref *_ptr;
+ }
+}
diff --git a/windows/src/test/utils/test-utils.csproj b/windows/src/test/utils/test-utils.csproj
new file mode 100644
index 0000000000..c9c7de15e2
--- /dev/null
+++ b/windows/src/test/utils/test-utils.csproj
@@ -0,0 +1,13 @@
+
+
+
+ Exe
+ net8.0-win
+
+
+
+
+
+
+
+
\ No newline at end of file