From 26c77d1f670a29fecc43df06ea7a4a06152eb1a1 Mon Sep 17 00:00:00 2001 From: MichalFrends1 Date: Thu, 21 Aug 2025 08:07:35 +0200 Subject: [PATCH] Implemented RemotePathType for credential separation. Tests not working. --- .../Frends.Files.Move.Tests.csproj | 3 +- .../ImpersonationTests.cs | 4 +- .../Samba/ImpersonationSambaDockerTests.cs | 292 ++++++++++++++++++ .../Samba/docker-compose.yml | 41 +++ .../Frends.Files.Move.Tests/UnitTests.cs | 6 +- .../WindowsImpersonationTests.cs | 138 +++++++++ .../WindowsServer/docker-compose.yml | 60 ++++ .../Frends.Files.Move/Definitions/Options.cs | 17 +- .../Definitions/RemotePathType.cs | 23 ++ Frends.Files.Move/Frends.Files.Move/Move.cs | 218 +++++++++++-- 10 files changed, 757 insertions(+), 45 deletions(-) create mode 100644 Frends.Files.Move/Frends.Files.Move.Tests/Samba/ImpersonationSambaDockerTests.cs create mode 100644 Frends.Files.Move/Frends.Files.Move.Tests/Samba/docker-compose.yml create mode 100644 Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/WindowsImpersonationTests.cs create mode 100644 Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/docker-compose.yml create mode 100644 Frends.Files.Move/Frends.Files.Move/Definitions/RemotePathType.cs diff --git a/Frends.Files.Move/Frends.Files.Move.Tests/Frends.Files.Move.Tests.csproj b/Frends.Files.Move/Frends.Files.Move.Tests/Frends.Files.Move.Tests.csproj index 06367a5..5b16cc3 100644 --- a/Frends.Files.Move/Frends.Files.Move.Tests/Frends.Files.Move.Tests.csproj +++ b/Frends.Files.Move/Frends.Files.Move.Tests/Frends.Files.Move.Tests.csproj @@ -1,4 +1,4 @@ - + net6.0 @@ -8,6 +8,7 @@ + diff --git a/Frends.Files.Move/Frends.Files.Move.Tests/ImpersonationTests.cs b/Frends.Files.Move/Frends.Files.Move.Tests/ImpersonationTests.cs index 3f5a2e6..0fd576d 100644 --- a/Frends.Files.Move/Frends.Files.Move.Tests/ImpersonationTests.cs +++ b/Frends.Files.Move/Frends.Files.Move.Tests/ImpersonationTests.cs @@ -38,7 +38,7 @@ public void OneTimeSetup() _options = new Options { - UseGivenUserCredentialsForRemoteConnections = true, + //UseGivenUserCredentialsForRemoteConnections = true, UserName = $"{_domain}\\{_name}", Password = _pwd }; @@ -80,7 +80,7 @@ public void FileMoveTestWithUsernameWithoutDomain() { var options = new Options { - UseGivenUserCredentialsForRemoteConnections = true, + //UseGivenUserCredentialsForRemoteConnections = true, UserName = "test", Password = _pwd }; diff --git a/Frends.Files.Move/Frends.Files.Move.Tests/Samba/ImpersonationSambaDockerTests.cs b/Frends.Files.Move/Frends.Files.Move.Tests/Samba/ImpersonationSambaDockerTests.cs new file mode 100644 index 0000000..a7718b6 --- /dev/null +++ b/Frends.Files.Move/Frends.Files.Move.Tests/Samba/ImpersonationSambaDockerTests.cs @@ -0,0 +1,292 @@ +using Frends.Files.Move.Definitions; +using NUnit.Framework; +using NUnit.Framework.Legacy; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Frends.Files.Move.Tests.Samba; + +[TestFixture] +public class ImpersonationSambaDockerTests +{ + private string _localSourceDir; + private string _localTargetDir; + private string _remoteDir; + private Options _options; + private bool _isDockerEnvironment; + + [OneTimeSetUp] + public void OneTimeSetup() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "ImpersonationDockerTests_" + Guid.NewGuid().ToString("N")[..8]); + _localSourceDir = Path.Combine(tempRoot, "Source"); + _localTargetDir = Path.Combine(tempRoot, "Target"); + Directory.CreateDirectory(_localSourceDir); + Directory.CreateDirectory(_localTargetDir); + + WaitForDockerContainer("impersonation_test_samba", TimeSpan.FromMinutes(2)); + + var connectionMethods = new[] + { + @"\\localhost:1445\testshare", // Docker port mapping + @"\\127.0.0.1:1445\testshare", // Localhost IP with port + GetContainerDirectPath(), // Container IP fallback + @"\\host.docker.internal:1445\testshare" // Docker Desktop host + }; + + _isDockerEnvironment = false; + string lastError = ""; + + foreach (var path in connectionMethods) + { + if (string.IsNullOrEmpty(path)) continue; + + try + { + if (TestSambaConnection(path, "testuser", "password123!", TimeSpan.FromSeconds(10))) + { + _remoteDir = path; + _isDockerEnvironment = true; + break; + } + } + catch (Exception ex) + { + lastError = ex.Message; + continue; + } + } + + if (!_isDockerEnvironment) + { + Assert.Ignore("Docker Samba environment not available"); + } + + _options = new Options + { + UserName = @"WORKGROUP\testuser", + Password = "password123!", + CreateTargetDirectories = true, + IfTargetFileExists = FileExistsAction.Overwrite + }; + } + + private void WaitForDockerContainer(string containerName, TimeSpan timeout) + { + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < timeout) + { + try + { + var psi = new ProcessStartInfo("docker", $"exec {containerName} smbclient -L //localhost/ -U testuser%password123! --option=\"client min protocol=SMB2\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process.WaitForExit(5000) && process.ExitCode == 0) + { + return; + } + } + catch { } + + Console.WriteLine($"⏳ Waiting for container {containerName} to be ready... ({stopwatch.Elapsed.TotalSeconds:F0}s)"); + Thread.Sleep(2000); + } + } + + private string GetContainerDirectPath() + { + try + { + var psi = new ProcessStartInfo("docker", "inspect impersonation_test_samba -f \"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process.WaitForExit(5000) && process.ExitCode == 0) + { + var ip = process.StandardOutput.ReadToEnd().Trim(); + if (!string.IsNullOrEmpty(ip) && ip != "localhost") + { + return $@"\\{ip}\testshare"; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to get container IP: {ex.Message}"); + } + + return null; + } + + private bool TestSambaConnection(string uncPath, string username, string password, TimeSpan timeout) + { + var host = ExtractHostFromUncPath(uncPath); + + var psi = new ProcessStartInfo("docker", $"exec impersonation_test_samba smbclient //{host}/testshare -U {username}%{password} -c dir") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + return process.WaitForExit((int)timeout.TotalMilliseconds) && process.ExitCode == 0; + } + + private string ExtractHostFromUncPath(string uncPath) + { + // Extract host + var parts = uncPath.TrimStart('\\').Split('\\'); + return parts.Length > 0 ? parts[0] : "localhost"; + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + var tempRoot = Path.GetDirectoryName(_localSourceDir); + if (Directory.Exists(tempRoot)) + { + Directory.Delete(tempRoot, true); + } + } + + [SetUp] + public void Setup() + { + Helper.CreateTestFiles(_localSourceDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_localTargetDir)) + { + Helper.DeleteTestFolder(_localTargetDir); + Directory.CreateDirectory(_localTargetDir); + } + + if (Directory.Exists(_localSourceDir)) + { + Helper.DeleteTestFolder(_localSourceDir); + Directory.CreateDirectory(_localSourceDir); + } + try + { + CleanRemoteDirectory(); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to clean remote directory: {ex.Message}"); + } + } + + private void CleanRemoteDirectory() + { + var psi = new ProcessStartInfo("docker", $"exec impersonation_test_samba find /mount/testshare -type f -delete") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + process.WaitForExit(10000); + } + + [Test] + public async Task RemoteToLocal_WithImpersonation_Success() + { + CreateRemoteTestFiles(); + + var input = new Input + { + Directory = _remoteDir, + Pattern = "*.txt", + TargetDirectory = _localTargetDir + }; + + _options.RemotePath = RemotePathType.Source; + + var result = await Files.Move(input, _options, default); + + ClassicAssert.AreEqual(5, result.Files.Count); + + foreach (var file in result.Files) + { + ClassicAssert.IsTrue(File.Exists(file.TargetPath)); + + var content = await File.ReadAllTextAsync(file.TargetPath); + ClassicAssert.IsNotEmpty(content); + + ClassicAssert.IsFalse(File.Exists(file.SourcePath)); + } + } + + [Test] + public async Task LocalToRemote_WithImpersonation_Success() + { + var input = new Input + { + Directory = _localSourceDir, + Pattern = "*.txt", + TargetDirectory = _remoteDir + }; + + _options.RemotePath = RemotePathType.Target; + + var result = await Files.Move(input, _options, default); + + ClassicAssert.AreEqual(5, result.Files.Count); + + foreach (var file in result.Files) + { + ClassicAssert.IsTrue(File.Exists(file.TargetPath)); + ClassicAssert.IsFalse(File.Exists(file.SourcePath)); + } + } + + private void CreateRemoteTestFiles() + { + // Use docker exec to create test files in the container + var commands = new[] + { + "echo 'Test1 content' > /mount/testshare/Test1.txt", + "echo 'Test2 content' > /mount/testshare/Test2.txt", + "echo 'Test3 content' > /mount/testshare/Test3.txt", + "echo 'Test4 content' > /mount/testshare/Test4.txt", + "echo 'Test5 content' > /mount/testshare/Test5.txt" + }; + + foreach (var command in commands) + { + var psi = new ProcessStartInfo("docker", $"exec impersonation_test_samba sh -c \"{command}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + process.WaitForExit(5000); + } + } +} \ No newline at end of file diff --git a/Frends.Files.Move/Frends.Files.Move.Tests/Samba/docker-compose.yml b/Frends.Files.Move/Frends.Files.Move.Tests/Samba/docker-compose.yml new file mode 100644 index 0000000..be4e8ab --- /dev/null +++ b/Frends.Files.Move/Frends.Files.Move.Tests/Samba/docker-compose.yml @@ -0,0 +1,41 @@ +# docker-compose.yml +version: '3.8' +services: + samba: + image: dperson/samba:latest + container_name: impersonation_test_samba + hostname: samba-test + ports: + - "1445:445" # Map to port 1445 to avoid conflicts + volumes: + - samba_data:/mount/testshare # Use named volume instead of tmpfs + environment: + - USER=testuser;password123! + - SHARE=testshare;/mount/testshare;yes;no;no;testuser;testuser;testuser + - SMB=true + - NMBD=true + - WORKGROUP=WORKGROUP + command: > + sh -c " + /usr/bin/samba.sh -u 'testuser;password123!' -s 'testshare;/mount/testshare;yes;no;no;testuser' -p && + chmod 777 /mount/testshare && + chown testuser:testuser /mount/testshare && + tail -f /dev/null + " + restart: unless-stopped + networks: + - test-net + healthcheck: + test: ["CMD", "smbclient", "-L", "//localhost/", "-U", "testuser%password123!", "--option=client min protocol=SMB2"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + samba_data: + driver: local + +networks: + test-net: + driver: bridge \ No newline at end of file diff --git a/Frends.Files.Move/Frends.Files.Move.Tests/UnitTests.cs b/Frends.Files.Move/Frends.Files.Move.Tests/UnitTests.cs index 753a375..51615dc 100644 --- a/Frends.Files.Move/Frends.Files.Move.Tests/UnitTests.cs +++ b/Frends.Files.Move/Frends.Files.Move.Tests/UnitTests.cs @@ -31,7 +31,7 @@ public void Setup() _options = new Options { - UseGivenUserCredentialsForRemoteConnections = false, + RemotePath = RemotePathType.None, CreateTargetDirectories = false, IfTargetFileExists = FileExistsAction.Throw, PreserveDirectoryStructure = true, @@ -59,7 +59,7 @@ public async Task FileMovePreserveDirectoryStructure() { var options = new Options { - UseGivenUserCredentialsForRemoteConnections = false, + RemotePath = RemotePathType.None, CreateTargetDirectories = false, IfTargetFileExists = FileExistsAction.Throw, PreserveDirectoryStructure = true @@ -76,7 +76,7 @@ public async Task FileMoveCreateTargetDirectories() { var options = new Options { - UseGivenUserCredentialsForRemoteConnections = false, + RemotePath = RemotePathType.None, CreateTargetDirectories = true, IfTargetFileExists = FileExistsAction.Throw, PreserveDirectoryStructure = true diff --git a/Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/WindowsImpersonationTests.cs b/Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/WindowsImpersonationTests.cs new file mode 100644 index 0000000..22ccca4 --- /dev/null +++ b/Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/WindowsImpersonationTests.cs @@ -0,0 +1,138 @@ +using Frends.Files.Move.Definitions; +using NUnit.Framework; +using System; +using System.IO; +using System.Threading.Tasks; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Frends.Files.Move.Tests.WindowsServer +{ + [TestFixture] + public class WindowsImpersonationTests + { + private string _localSourceDir; + private string _localTargetDir; + private string _remoteShare; + private Options _options; + + [OneTimeSetUp] + public void OneTimeSetup() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Assert.Ignore("Windows only tests"); + + // Simple manual setup - just set these values for your environment + //_remoteShare = @"\\172.24.187.181%3a1445\testshare"; + //_remoteShare = @"\\windows_fileserver\testshare"; + _remoteShare = @"\\127.0.0.1\testshare"; + _options = new Options + { + UserName = @"testfileuser", + Password = "Password123!", + CreateTargetDirectories = true, + IfTargetFileExists = FileExistsAction.Overwrite + }; + + // Create temp directories + var tempRoot = Path.Combine(Path.GetTempPath(), "ImpersonationTests"); + _localSourceDir = Path.Combine(tempRoot, "Source"); + _localTargetDir = Path.Combine(tempRoot, "Target"); + Directory.CreateDirectory(_localSourceDir); + Directory.CreateDirectory(_localTargetDir); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + var tempRoot = Path.GetDirectoryName(_localSourceDir); + if (Directory.Exists(tempRoot)) + Directory.Delete(tempRoot, true); + } + + [SetUp] + public void Setup() + { + // Create local test files + for (int i = 1; i <= 3; i++) + { + File.WriteAllText(Path.Combine(_localSourceDir, $"test{i}.txt"), $"Content {i}"); + } + + // Clean remote directory + try + { + Files.ExecuteWithImpersonation(_options, async () => + { + if (Directory.Exists(_remoteShare)) + { + foreach (var file in Directory.GetFiles(_remoteShare)) + File.Delete(file); + } + await Task.CompletedTask; + }).Wait(); + } + catch { } + } + + [Test] + public async Task RemoteToLocal_MovesFiles() + { + // Arrange - Create files on remote + await Files.ExecuteWithImpersonation(_options, async () => + { + for (int i = 1; i <= 3; i++) + { + await File.WriteAllTextAsync(Path.Combine(_remoteShare, $"remote{i}.txt"), $"Remote content {i}"); + } + }); + + var input = new Input + { + Directory = _remoteShare, + Pattern = "*.txt", + TargetDirectory = _localTargetDir + }; + _options.RemotePath = RemotePathType.Source; + + var result = await Files.Move(input, _options, CancellationToken.None); + + Assert.That(result.Files.Count, Is.EqualTo(3)); + foreach (var file in result.Files) + { + Assert.That(File.Exists(file.TargetPath), Is.True); + Assert.That(File.Exists(file.SourcePath), Is.False); + } + } + + [Test] + public async Task LocalToRemote_MovesFiles() + { + var input = new Input + { + Directory = _localSourceDir, + Pattern = "*.txt", + TargetDirectory = _remoteShare + }; + _options.RemotePath = RemotePathType.Target; + + var result = await Files.Move(input, _options, CancellationToken.None); + + Assert.That(result.Files.Count, Is.EqualTo(3)); + + await Files.ExecuteWithImpersonation(_options, async () => + { + foreach (var file in result.Files) + { + Assert.That(File.Exists(file.TargetPath), Is.True); + } + await Task.CompletedTask; + }); + + foreach (var file in result.Files) + { + Assert.That(File.Exists(file.SourcePath), Is.False); + } + } + } +} \ No newline at end of file diff --git a/Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/docker-compose.yml b/Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/docker-compose.yml new file mode 100644 index 0000000..ad4347a --- /dev/null +++ b/Frends.Files.Move/Frends.Files.Move.Tests/WindowsServer/docker-compose.yml @@ -0,0 +1,60 @@ +# docker-compose-windows.yml +version: '3.8' +services: + windows-fileserver: + image: mcr.microsoft.com/windows/servercore:ltsc2022 + container_name: windows_fileserver + hostname: fileserver + volumes: + - fileserver_data:C:\SharedData + networks: + - test-net + command: + - powershell + - -Command + - | + Write-Host 'Starting Windows file server setup...' + + # Create test user + net user testfileuser Password123 /add + net localgroup Administrators testfileuser /add + + # Create shared folder + New-Item -Path 'C:\TestShare' -ItemType Directory -Force + + # Use net share instead of New-SmbShare - this works without Server service! + net share testshare=C:\TestShare /GRANT:testfileuser,FULL /GRANT:Administrator,FULL + + # Set NTFS permissions + icacls 'C:\TestShare' /grant 'testfileuser:(OI)(CI)F' /T + + # Create test files + 'Test content 1' | Out-File 'C:\TestShare\test1.txt' -Encoding UTF8 + 'Test content 2' | Out-File 'C:\TestShare\test2.txt' -Encoding UTF8 + 'Test content 3' | Out-File 'C:\TestShare\test3.txt' -Encoding UTF8 + + Write-Host 'Setup complete - container ready' + + # Show share status + net share + + # Keep container running + while ($$true) { + Start-Sleep 60 + } + healthcheck: + test: ["CMD", "powershell", "-Command", "net share testshare"] + interval: 30s + timeout: 15s + retries: 5 + start_period: 60s + ports: + - "1445:445" + +volumes: + fileserver_data: + driver: local + +networks: + test-net: + driver: nat \ No newline at end of file diff --git a/Frends.Files.Move/Frends.Files.Move/Definitions/Options.cs b/Frends.Files.Move/Frends.Files.Move/Definitions/Options.cs index ae1699f..b831ce5 100644 --- a/Frends.Files.Move/Frends.Files.Move/Definitions/Options.cs +++ b/Frends.Files.Move/Frends.Files.Move/Definitions/Options.cs @@ -9,20 +9,21 @@ namespace Frends.Files.Move.Definitions; public class Options { /// - /// If set, allows you to give the user credentials to use to delete files on remote hosts. - /// If not set, the agent service user credentials will be used. - /// Note: This feature is only possible with Windows agents. + /// Specifies which path (source or target) is remote to determine when to use + /// remote credentials and Windows impersonation for file operations. + /// This allows the method to handle local-to-remote, remote-to-local, + /// or local-to-local file copy operations appropriately. /// - /// true - [DefaultValue(false)] - public bool UseGivenUserCredentialsForRemoteConnections { get; set; } + /// RemotePathType.Source + [DefaultValue(RemotePathType.None)] + public RemotePathType RemotePath { get; set; } /// /// This needs to be of format domain\username /// /// domain\username [DefaultValue("\"domain\\username\"")] - [UIHint(nameof(UseGivenUserCredentialsForRemoteConnections), "", true)] + [UIHint(nameof(RemotePath), "", RemotePathType.Source, RemotePathType.Target)] public string UserName { get; set; } /// @@ -30,7 +31,7 @@ public class Options /// /// testpwd [PasswordPropertyText] - [UIHint(nameof(UseGivenUserCredentialsForRemoteConnections), "", true)] + [UIHint(nameof(RemotePath), "", RemotePathType.Source, RemotePathType.Target)] public string Password { get; set; } /// diff --git a/Frends.Files.Move/Frends.Files.Move/Definitions/RemotePathType.cs b/Frends.Files.Move/Frends.Files.Move/Definitions/RemotePathType.cs new file mode 100644 index 0000000..d1fb936 --- /dev/null +++ b/Frends.Files.Move/Frends.Files.Move/Definitions/RemotePathType.cs @@ -0,0 +1,23 @@ +namespace Frends.Files.Move.Definitions; + +/// +/// Defines the types of remote path configurations for file operations. +/// Used to identify which path requires remote credentials and impersonation. +/// +public enum RemotePathType +{ + /// + /// Both source and target paths are local. No remote credentials needed. + /// + None, + + /// + /// The source path is remote. Remote credentials will be used for reading from source. + /// + Source, + + /// + /// The target path is remote. Remote credentials will be used for writing to target. + /// + Target +} diff --git a/Frends.Files.Move/Frends.Files.Move/Move.cs b/Frends.Files.Move/Frends.Files.Move/Move.cs index 902f7ce..2e5fe50 100644 --- a/Frends.Files.Move/Frends.Files.Move/Move.cs +++ b/Frends.Files.Move/Frends.Files.Move/Move.cs @@ -30,39 +30,49 @@ public class Files /// Result object { List<FileItem> } public static async Task Move([PropertyTab] Input input, [PropertyTab] Options options, CancellationToken cancellationToken) { - var result = await ExecuteAction(() => ExecuteMoveAsync(input, options, cancellationToken), - options.UseGivenUserCredentialsForRemoteConnections, options.UserName, options.Password).ConfigureAwait(false); - + var result = await ExecuteMoveAsync(input, options, cancellationToken); return new Result(result); } - private static TResult ExecuteAction(Func action, bool useGivenCredentials, string username, string password) + private static async Task> ExecuteMoveAsync(Input input, Options options, CancellationToken cancellationToken) { - if (!useGivenCredentials) - return action(); - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - throw new PlatformNotSupportedException("UseGivenCredentials feature is only supported on Windows."); - - var (domain, user) = GetDomainAndUsername(username); + bool needsImpersonationForDiscovery = options.RemotePath == RemotePathType.Source; - UserCredentials credentials = new UserCredentials(domain, user, password); - using SafeAccessTokenHandle userHandle = credentials.LogonUser(LogonType.NewCredentials); - - return WindowsIdentity.RunImpersonated(userHandle, () => action()); + PatternMatchingResult results; - } + if (needsImpersonationForDiscovery) + { + // Find files with impersonation (remote source) + results = await FindMatchingFilesWithImpersonation(input.Directory, input.Pattern, options); + } + else + { + // Find files with local credentials (local source) + results = FindMatchingFiles(input.Directory, input.Pattern); + } - private static async Task> ExecuteMoveAsync(Input input, Options options, CancellationToken cancellationToken) - { - var results = FindMatchingFiles(input.Directory, input.Pattern); var fileTransferEntries = GetFileTransferEntries(results.Files, input.Directory, input.TargetDirectory, options.PreserveDirectoryStructure); if (options.IfTargetFileExists == FileExistsAction.Throw) AssertNoTargetFileConflicts(fileTransferEntries.Values); if (options.CreateTargetDirectories) - Directory.CreateDirectory(input.TargetDirectory); + { + bool needsImpersonationForTargetDir = options.RemotePath == RemotePathType.Target; + + if (needsImpersonationForTargetDir && !string.IsNullOrEmpty(options.UserName)) + { + await ExecuteWithImpersonation(options, async () => + { + Directory.CreateDirectory(input.TargetDirectory); + await Task.CompletedTask; + }); + } + else + { + Directory.CreateDirectory(input.TargetDirectory); + } + } var fileResults = new List(); try @@ -75,41 +85,123 @@ private static async Task> ExecuteMoveAsync(Input input, Options var targetFilePath = entry.Value; if (options.CreateTargetDirectories) - Directory.CreateDirectory(Path.GetDirectoryName(targetFilePath)); + { + var targetDir = Path.GetDirectoryName(targetFilePath); + bool needsImpersonationForFileDir = options.RemotePath == RemotePathType.Target; + + if (needsImpersonationForFileDir && !string.IsNullOrEmpty(options.UserName)) + { + await ExecuteWithImpersonation(options, async () => + { + Directory.CreateDirectory(targetDir); + await Task.CompletedTask; + }); + } + else + { + Directory.CreateDirectory(targetDir); + } + } switch (options.IfTargetFileExists) { case FileExistsAction.Rename: targetFilePath = GetNonConflictingDestinationFilePath(sourceFilePath, targetFilePath); - await CopyFileAsync(sourceFilePath, targetFilePath, cancellationToken); break; - case FileExistsAction.Overwrite: + bool needsImpersonationForDelete = options.RemotePath == RemotePathType.Target; + if (File.Exists(targetFilePath)) - File.Delete(targetFilePath); - await CopyFileAsync(sourceFilePath, targetFilePath, cancellationToken).ConfigureAwait(false); + { + if (needsImpersonationForDelete && !string.IsNullOrEmpty(options.UserName)) + { + await ExecuteWithImpersonation(options, async () => + { + File.Delete(targetFilePath); + await Task.CompletedTask; + }); + } + else + { + File.Delete(targetFilePath); + } + } break; - case FileExistsAction.Throw: - if (File.Exists(targetFilePath)) + + bool needsImpersonationForCheck = options.RemotePath == RemotePathType.Target; + + bool fileExists; + if (needsImpersonationForCheck && !string.IsNullOrEmpty(options.UserName)) + { + fileExists = false; + await ExecuteWithImpersonation(options, async () => + { + fileExists = File.Exists(targetFilePath); + await Task.CompletedTask; + }); + } + else + { + fileExists = File.Exists(targetFilePath); + } + + if (fileExists) throw new IOException($"File '{targetFilePath}' already exists. No files moved."); - await CopyFileAsync(sourceFilePath, targetFilePath, cancellationToken).ConfigureAwait(false); break; } + + // Copy according to remote mode + await ExecuteCopyAsync(sourceFilePath, targetFilePath, options, cancellationToken); + fileResults.Add(new FileItem(sourceFilePath, targetFilePath)); } } catch (Exception) { - //Delete the target files that were already moved before a file that exists breaks the move command - DeleteExistingFiles(fileResults.Select(x => x.TargetPath)); + // Clean up target files - also needs proper credentials + await DeleteExistingFilesWithCredentials(fileResults.Select(x => x.TargetPath), options, isTarget: true); throw; } - DeleteExistingFiles(fileResults.Select(x => x.SourcePath)); + // Delete source files - also needs proper credentials + await DeleteExistingFilesWithCredentials(fileResults.Select(x => x.SourcePath), options, isTarget: false); return fileResults; } + private static async Task FindMatchingFilesWithImpersonation(string directoryPath, string pattern, Options options) + { + PatternMatchingResult results = null; + + await ExecuteWithImpersonation(options, async () => + { + results = FindMatchingFiles(directoryPath, pattern); + await Task.CompletedTask; + }); + + return results; + } + + private static async Task DeleteExistingFilesWithCredentials(IEnumerable filePaths, Options options, bool isTarget) + { + bool needsImpersonation = (isTarget && options.RemotePath == RemotePathType.Target) || + (!isTarget && (options.RemotePath == RemotePathType.Source)); + + if (needsImpersonation && !string.IsNullOrEmpty(options.UserName)) + { + await ExecuteWithImpersonation(options, async () => + { + DeleteExistingFiles(filePaths); + await Task.CompletedTask; + }); + } + else + { + DeleteExistingFiles(filePaths); + } + } + + private static async Task CopyFileAsync(string source, string destination, CancellationToken cancellationToken) { using FileStream sourceStream = File.Open(source, FileMode.Open); @@ -131,7 +223,19 @@ internal static PatternMatchingResult FindMatchingFiles(string directoryPath, st // Check the user can access the folder // This will return false if the path does not exist or you do not have read permissions. if (!Directory.Exists(directoryPath)) + { + try + { + var entries = Directory.GetFileSystemEntries(directoryPath); + Console.WriteLine($"[DEBUG] GetFileSystemEntries succeeded, found {entries.Length} items"); + } + catch (Exception ex) + { + Console.WriteLine($"[DEBUG] GetFileSystemEntries failed: {ex.Message}"); + } + throw new DirectoryNotFoundException($"Directory does not exist or you do not have read access. Tried to access directory '{directoryPath}'"); + } if (pattern.StartsWith("")) { @@ -199,4 +303,56 @@ internal static string GetNonConflictingDestinationFilePath(string sourceFilePat return destFilePath; } + + private static async Task ExecuteCopyAsync(string source, string target, Options options, CancellationToken cancellationToken) + { + switch (options.RemotePath) + { + case RemotePathType.None: + // Local → Local + await CopyFileAsync(source, target, cancellationToken); + break; + + case RemotePathType.Target: + // Local → Remote + { + await using var sourceStream = File.Open(source, FileMode.Open, FileAccess.Read); + await ExecuteWithImpersonation(options, async () => + { + await using var targetStream = File.Open(target, FileMode.CreateNew, FileAccess.Write); + await sourceStream.CopyToAsync(targetStream, 81920, cancellationToken); + }); + break; + } + + case RemotePathType.Source: + { + // Remote → Local + await using var localTargetStream = File.Open(target, FileMode.CreateNew, FileAccess.Write); + + await ExecuteWithImpersonation(options, async () => + { + await using var remoteSourceStream = File.Open(source, FileMode.Open, FileAccess.Read); + await remoteSourceStream.CopyToAsync(localTargetStream, 81920, cancellationToken); + }); + + break; + } + + default: + throw new NotSupportedException($"RemotePathType {options.RemotePath} not supported."); + } + } + + public static async Task ExecuteWithImpersonation(Options options, Func action) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + throw new PlatformNotSupportedException("Impersonation is only supported on Windows."); + + var (domain, user) = GetDomainAndUsername(options.UserName); + UserCredentials credentials = new UserCredentials(domain, user, options.Password); + + using SafeAccessTokenHandle userHandle = credentials.LogonUser(LogonType.NewCredentials); + await WindowsIdentity.RunImpersonated(userHandle, action); + } }