diff --git a/.cascade/instructions.md b/.cascade/instructions.md new file mode 100644 index 00000000..0f18b4ee --- /dev/null +++ b/.cascade/instructions.md @@ -0,0 +1,110 @@ +# SMBLibrary Agent Instructions + +## Project Overview + +SMBLibrary is an open-source C# SMB 1.0/CIFS, SMB 2.0, SMB 2.1 and SMB 3.0 server and client implementation. It provides .NET developers with a way to share directories and virtual file systems with any OS that supports the SMB protocol. + +## Repository Structure + +``` +SMBLibrary/ +├── SMBLibrary/ # Core library +│ ├── Client/ # SMB client implementations +│ │ ├── DFS/ # DFS client support (PR #326) +│ │ ├── SMB1Client.cs # SMB1 client +│ │ ├── SMB2Client.cs # SMB2/3 client +│ │ ├── SMB1FileStore.cs # SMB1 file store operations +│ │ └── SMB2FileStore.cs # SMB2 file store operations +│ ├── DFS/ # DFS protocol data structures (MS-DFSC) +│ ├── Server/ # SMB server implementation +│ ├── SMB1/ # SMB1 protocol structures +│ ├── SMB2/ # SMB2/3 protocol structures +│ ├── NTFileStore/ # NT file store abstractions +│ ├── Authentication/ # Auth mechanisms (NTLM, etc.) +│ ├── NetBios/ # NetBIOS over TCP +│ ├── RPC/ # RPC/DCE structures +│ └── Services/ # Named pipe services +├── SMBLibrary.Win32/ # Windows-specific integrations +├── SMBLibrary.Adapters/ # IFileSystem to INTFileStore adapters +├── SMBLibrary.Tests/ # Unit tests +├── SMBServer/ # Example server application +└── Utilities/ # Shared utilities +``` + +## Coding Conventions + +### Style Guidelines +- **Naming**: PascalCase for public members, camelCase for private fields +- **Braces**: Allman style (opening brace on new line) +- **Indentation**: 4 spaces +- **Line endings**: CRLF (Windows) +- **No trailing whitespace** + +### Code Organization +- Protocol data structures go in `SMBLibrary/SMB1/`, `SMBLibrary/SMB2/`, or `SMBLibrary/DFS/` +- Client logic goes in `SMBLibrary/Client/` +- Server logic goes in `SMBLibrary/Server/` +- Each protocol message/structure should have its own file +- Include `GetBytes()` for serialization and constructor from `byte[]` for parsing + +### Documentation +- XML doc comments on public APIs +- Reference MS-* spec sections in comments (e.g., `/// [MS-DFSC] 2.2.3`) +- Keep external docs minimal; link to Microsoft specs for details + +### Testing +- Unit tests in `SMBLibrary.Tests/` +- Round-trip tests for protocol structures (parse → serialize → compare) +- Name test files as `{ClassName}Tests.cs` + +## Microsoft Protocol Specifications + +Key specifications for this codebase: +- **[MS-SMB]**: SMB 1.0/CIFS Protocol +- **[MS-SMB2]**: SMB 2.x and 3.x Protocols +- **[MS-DFSC]**: DFS Referral Protocol (for DFS client support) +- **[MS-FSCC]**: File System Control Codes +- **[MS-ERREF]**: Windows Error Codes + +See `docs/MS-DFSC-SUMMARY.md` for DFS-specific implementation guidance. + +## DFS Implementation Notes (PR #326) + +### Architecture +- DFS support is **opt-in** via `DfsClientFactory.CreateDfsAwareFileStore()` +- Does not modify core `SMB2Client` or `ISMBFileStore` interfaces +- Uses `FSCTL_DFS_GET_REFERRALS` / `FSCTL_DFS_GET_REFERRALS_EX` IOCTLs + +### Key Components +- `DfsPathResolver`: Resolves DFS paths to actual server/share paths +- `DfsAwareClientAdapter`: Wraps `ISMBFileStore` with DFS resolution +- `ReferralCache`: Caches referral responses per MS-DFSC +- `DomainCache`: Caches domain/DC information +- `DfsSessionManager`: Manages SMB sessions across multiple servers + +### Protocol Structures (SMBLibrary/DFS/) +- `RequestGetDfsReferral`: REQ_GET_DFS_REFERRAL (section 2.2.2) +- `RequestGetDfsReferralEx`: REQ_GET_DFS_REFERRAL_EX (section 2.2.3) +- `ResponseGetDfsReferral`: RESP_GET_DFS_REFERRAL (section 2.2.4) +- `DfsReferralEntryV1-V4`: Referral entry structures (section 2.2.5) + +### Important: REQ_GET_DFS_REFERRAL_EX Format +Per MS-DFSC 2.2.3.1, the `RequestData` field must include length prefixes: +``` +RequestFileNameLength (2 bytes) +RequestFileName (variable, Unicode) +SiteNameLength (2 bytes) - optional, when SiteName flag set +SiteName (variable, Unicode) - optional +``` + +## Pull Request Guidelines + +- Keep PRs small and focused +- Include unit tests for new protocol structures +- Maintain backward compatibility +- Reference MS-* spec sections for protocol changes +- Tal prefers C# only (no PowerShell scripts in repo) + +## Contribution Terms + +By contributing, you agree to irrevocably assign worldwide copyright and IP rights to SMBLibrary and/or Tal Aloni. diff --git a/.gitignore b/.gitignore index 78b49ee6..792274e9 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,5 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk +.windsurf/ +.cascade/ diff --git a/ClientExamples.md b/ClientExamples.md index dd9a5172..5df9d191 100644 --- a/ClientExamples.md +++ b/ClientExamples.md @@ -34,6 +34,39 @@ if (status == NTStatus.STATUS_SUCCESS) status = fileStore.Disconnect(); ``` +Enable DFS support via client property (recommended): +====================================================== +```cs +SMB2Client client = new SMB2Client(); +client.DfsClientOptions = new DfsClientOptions { Enabled = true }; +// TreeConnect automatically wraps DFS-enabled shares with DfsAwareClientAdapter +ISMBFileStore fileStore = client.TreeConnect("DfsShare", out status); +``` + +Enable DFS support on a file store: +=================================== +```cs +ISMBFileStore fileStore = client.TreeConnect("Shared", out status); +if (status == NTStatus.STATUS_SUCCESS) +{ + // Wrap with DFS support (disabled by default) + DfsClientOptions options = new DfsClientOptions { Enabled = true }; + ISMBFileStore dfsStore = DfsClientFactory.CreateDfsAwareFileStore(fileStore, null, options); + + // Use dfsStore normally - DFS paths are resolved automatically + object directoryHandle; + FileStatus fileStatus; + status = dfsStore.CreateFile(out directoryHandle, out fileStatus, @"\DfsLink\Subfolder", AccessMask.GENERIC_READ, FileAttributes.Directory, ShareAccess.Read | ShareAccess.Write, CreateDisposition.FILE_OPEN, CreateOptions.FILE_DIRECTORY_FILE, null); + if (status == NTStatus.STATUS_SUCCESS) + { + List fileList; + status = dfsStore.QueryDirectory(out fileList, directoryHandle, "*", FileInformationClass.FileDirectoryInformation); + status = dfsStore.CloseFile(directoryHandle); + } +} +status = fileStore.Disconnect(); +``` + Connect to share and list files and directories - SMB2: ======================================================= ```cs diff --git a/Readme.md b/Readme.md index aacf20d2..a8278ce5 100644 --- a/Readme.md +++ b/Readme.md @@ -21,6 +21,10 @@ Server notes can be found [here](ServerNotes.md). Client code examples can be found [here](ClientExamples.md). +DFS Client Support: +=================== +SMBLibrary provides opt-in DFS (Distributed File System) client support. Wrap any `ISMBFileStore` with `DfsClientFactory.CreateDfsAwareFileStore()` to enable automatic DFS path resolution. See [ClientExamples.md](ClientExamples.md) for usage. + NuGet Packages: =============== [SMBLibrary](https://www.nuget.org/packages/SMBLibrary/) - Cross-platform server and client implementation. diff --git a/SMBLibrary.Tests/Client/DfsAwareClientAdapterTests.cs b/SMBLibrary.Tests/Client/DfsAwareClientAdapterTests.cs new file mode 100644 index 00000000..e5e1de8e --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsAwareClientAdapterTests.cs @@ -0,0 +1,468 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsAwareClientAdapterTests + { + private class FakeResolver : IDfsClientResolver + { + public DfsResolutionResult ResultToReturn; + public int ResolveCallCount = 0; + public List PathsRequested = new List(); + + /// + /// If set, returns this result on subsequent calls (for retry scenarios). + /// + public DfsResolutionResult SecondResultToReturn; + + public DfsResolutionResult Resolve(DfsClientOptions options, string originalPath) + { + ResolveCallCount++; + PathsRequested.Add(originalPath); + + // Return second result on retry calls + if (SecondResultToReturn != null && ResolveCallCount > 1) + { + return SecondResultToReturn; + } + + return ResultToReturn; + } + } + + private class FakeFileStore : ISMBFileStore + { + public string LastPath; + public string LastQueryFileName; + public NTStatus StatusToReturn = NTStatus.STATUS_SUCCESS; + public int CreateFileCallCount = 0; + public List PathsReceived = new List(); + + /// + /// If set, the first N calls return STATUS_PATH_NOT_COVERED, then StatusToReturn. + /// + public int ReturnNotCoveredForFirstNCalls = 0; + + public NTStatus CreateFile(out object handle, out FileStatus fileStatus, string path, AccessMask desiredAccess, FileAttributes fileAttributes, ShareAccess shareAccess, CreateDisposition createDisposition, CreateOptions createOptions, SecurityContext securityContext) + { + CreateFileCallCount++; + PathsReceived.Add(path); + LastPath = path; + handle = new object(); + fileStatus = FileStatus.FILE_OPENED; + + if (ReturnNotCoveredForFirstNCalls > 0 && CreateFileCallCount <= ReturnNotCoveredForFirstNCalls) + { + return NTStatus.STATUS_PATH_NOT_COVERED; + } + + return StatusToReturn; + } + + public NTStatus CloseFile(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus ReadFile(out byte[] data, object handle, long offset, int maxCount) + { + throw new NotImplementedException(); + } + + public NTStatus WriteFile(out int numberOfBytesWritten, object handle, long offset, byte[] data) + { + throw new NotImplementedException(); + } + + public NTStatus FlushFileBuffers(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus LockFile(object handle, long byteOffset, long length, bool exclusiveLock) + { + throw new NotImplementedException(); + } + + public NTStatus UnlockFile(object handle, long byteOffset, long length) + { + throw new NotImplementedException(); + } + + public NTStatus QueryDirectory(out List result, object handle, string fileName, FileInformationClass informationClass) + { + LastQueryFileName = fileName; + result = new List(); + return StatusToReturn; + } + + public NTStatus GetFileInformation(out FileInformation result, object handle, FileInformationClass informationClass) + { + throw new NotImplementedException(); + } + + public NTStatus SetFileInformation(object handle, FileInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetFileSystemInformation(out FileSystemInformation result, FileSystemInformationClass informationClass) + { + throw new NotImplementedException(); + } + + public NTStatus SetFileSystemInformation(FileSystemInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetSecurityInformation(out SecurityDescriptor result, object handle, SecurityInformation securityInformation) + { + throw new NotImplementedException(); + } + + public NTStatus SetSecurityInformation(object handle, SecurityInformation securityInformation, SecurityDescriptor securityDescriptor) + { + throw new NotImplementedException(); + } + + public NTStatus NotifyChange(out object ioRequest, object handle, NotifyChangeFilter completionFilter, bool watchTree, int outputBufferSize, OnNotifyChangeCompleted onNotifyChangeCompleted, object context) + { + throw new NotImplementedException(); + } + + public NTStatus Cancel(object ioRequest) + { + throw new NotImplementedException(); + } + + public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) + { + throw new NotImplementedException(); + } + + public NTStatus Disconnect() + { + throw new NotImplementedException(); + } + + public uint MaxReadSize + { + get { return 0; } + } + + public uint MaxWriteSize + { + get { return 0; } + } + } + + [TestMethod] + public void CreateFile_WhenResolverReturnsNotApplicable_UsesOriginalPath() + { + FakeFileStore inner = new FakeFileStore(); + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\server\\share\\path"; + + resolver.ResultToReturn = new DfsResolutionResult(); + resolver.ResultToReturn.Status = DfsResolutionStatus.NotApplicable; + resolver.ResultToReturn.ResolvedPath = originalPath; + resolver.ResultToReturn.OriginalPath = originalPath; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + object handle; + FileStatus fileStatus; + NTStatus status = adapter.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + Assert.AreEqual(originalPath, inner.LastPath); + Assert.AreEqual(inner.StatusToReturn, status); + } + + [TestMethod] + public void CreateFile_WhenResolverReturnsSuccess_UsesResolvedPath() + { + FakeFileStore inner = new FakeFileStore(); + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\server\\share\\path"; + string resolvedPath = @"\\target\\share\\path"; + + resolver.ResultToReturn = new DfsResolutionResult(); + resolver.ResultToReturn.Status = DfsResolutionStatus.Success; + resolver.ResultToReturn.ResolvedPath = resolvedPath; + resolver.ResultToReturn.OriginalPath = originalPath; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + object handle; + FileStatus fileStatus; + adapter.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + Assert.AreEqual(resolvedPath, inner.LastPath); + } + + [TestMethod] + public void QueryDirectory_WhenResolverReturnsNotApplicable_UsesOriginalFileName() + { + FakeFileStore inner = new FakeFileStore(); + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\server\\share\\dir\\*"; + + resolver.ResultToReturn = new DfsResolutionResult(); + resolver.ResultToReturn.Status = DfsResolutionStatus.NotApplicable; + resolver.ResultToReturn.ResolvedPath = originalPath; + resolver.ResultToReturn.OriginalPath = originalPath; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + List result; + NTStatus status = adapter.QueryDirectory(out result, null, originalPath, FileInformationClass.FileDirectoryInformation); + + Assert.AreEqual(originalPath, inner.LastQueryFileName); + Assert.AreEqual(inner.StatusToReturn, status); + } + + [TestMethod] + public void QueryDirectory_WhenResolverReturnsSuccess_UsesResolvedPath() + { + FakeFileStore inner = new FakeFileStore(); + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\server\\share\\dir\\*"; + string resolvedPath = @"\\target\\share\\dir\\*"; + + resolver.ResultToReturn = new DfsResolutionResult(); + resolver.ResultToReturn.Status = DfsResolutionStatus.Success; + resolver.ResultToReturn.ResolvedPath = resolvedPath; + resolver.ResultToReturn.OriginalPath = originalPath; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + List result; + adapter.QueryDirectory(out result, null, originalPath, FileInformationClass.FileDirectoryInformation); + + Assert.AreEqual(resolvedPath, inner.LastQueryFileName); + } + + #region STATUS_PATH_NOT_COVERED Retry Tests + + [TestMethod] + public void CreateFile_WhenPathNotCovered_RetriesWithResolvedPath() + { + // Arrange + FakeFileStore inner = new FakeFileStore(); + inner.ReturnNotCoveredForFirstNCalls = 1; // First call fails, second succeeds + + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\domain\dfs\folder"; + string resolvedPath = @"\\server\share\folder"; + + // First call returns original (no resolution yet) + resolver.ResultToReturn = new DfsResolutionResult + { + Status = DfsResolutionStatus.NotApplicable, + ResolvedPath = originalPath, + OriginalPath = originalPath + }; + + // Second call (after PATH_NOT_COVERED) returns resolved path + resolver.SecondResultToReturn = new DfsResolutionResult + { + Status = DfsResolutionStatus.Success, + ResolvedPath = resolvedPath, + OriginalPath = originalPath + }; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + // Act + object handle; + FileStatus fileStatus; + NTStatus status = adapter.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + Assert.AreEqual(2, inner.CreateFileCallCount, "Should have retried once"); + Assert.AreEqual(originalPath, inner.PathsReceived[0], "First attempt should use original path"); + Assert.AreEqual(resolvedPath, inner.PathsReceived[1], "Retry should use resolved path"); + } + + [TestMethod] + public void CreateFile_WhenPathNotCovered_AndResolutionFails_ReturnsError() + { + // Arrange + FakeFileStore inner = new FakeFileStore(); + inner.ReturnNotCoveredForFirstNCalls = 10; // Always fails + + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\domain\dfs\folder"; + + // Resolution always returns null/failure + resolver.ResultToReturn = new DfsResolutionResult + { + Status = DfsResolutionStatus.Error, + ResolvedPath = null, + OriginalPath = originalPath + }; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + // Act + object handle; + FileStatus fileStatus; + NTStatus status = adapter.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + // Assert - Should return the error without infinite retries + Assert.AreEqual(NTStatus.STATUS_PATH_NOT_COVERED, status); + Assert.AreEqual(1, inner.CreateFileCallCount, "Should not retry when resolution fails"); + } + + [TestMethod] + public void CreateFile_WhenPathNotCovered_AndSamePathReturned_StopsRetrying() + { + // Arrange + FakeFileStore inner = new FakeFileStore(); + inner.ReturnNotCoveredForFirstNCalls = 10; // Always fails + + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\domain\dfs\folder"; + + // Resolution returns same path (no resolution available) + resolver.ResultToReturn = new DfsResolutionResult + { + Status = DfsResolutionStatus.Success, + ResolvedPath = originalPath, + OriginalPath = originalPath + }; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + // Act + object handle; + FileStatus fileStatus; + NTStatus status = adapter.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + // Assert - Should not infinitely retry with same path + Assert.AreEqual(NTStatus.STATUS_PATH_NOT_COVERED, status); + Assert.AreEqual(1, inner.CreateFileCallCount, "Should not retry when path doesn't change"); + } + + [TestMethod] + public void CreateFile_WhenPathNotCovered_RespectsMaxRetries() + { + // Arrange + FakeFileStore inner = new FakeFileStore(); + inner.ReturnNotCoveredForFirstNCalls = 100; // Always fails + + DfsClientOptions options = new DfsClientOptions(); + string basePath = @"\\domain\dfs\folder"; + + // Create a resolver that returns incrementing paths (simulating interlink loops) + var loopingResolver = new LoopingResolver(); + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, loopingResolver, options, maxRetries: 3); + + // Act + object handle; + FileStatus fileStatus; + NTStatus status = adapter.CreateFile(out handle, out fileStatus, basePath, 0, 0, 0, 0, 0, null); + + // Assert - Should stop after max retries + Assert.AreEqual(NTStatus.STATUS_PATH_NOT_COVERED, status); + Assert.IsTrue(inner.CreateFileCallCount <= 4, "Should stop after max retries (initial + 3 retries)"); + } + + [TestMethod] + public void CreateFile_WhenSuccessOnFirstTry_NoRetry() + { + // Arrange + FakeFileStore inner = new FakeFileStore(); + inner.StatusToReturn = NTStatus.STATUS_SUCCESS; + + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\server\share\file"; + + resolver.ResultToReturn = new DfsResolutionResult + { + Status = DfsResolutionStatus.NotApplicable, + ResolvedPath = originalPath, + OriginalPath = originalPath + }; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + // Act + object handle; + FileStatus fileStatus; + NTStatus status = adapter.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + Assert.AreEqual(1, inner.CreateFileCallCount, "Should only call once on success"); + } + + [TestMethod] + public void CreateFile_WhenOtherError_NoRetry() + { + // Arrange + FakeFileStore inner = new FakeFileStore(); + inner.StatusToReturn = NTStatus.STATUS_ACCESS_DENIED; + + FakeResolver resolver = new FakeResolver(); + DfsClientOptions options = new DfsClientOptions(); + string originalPath = @"\\server\share\file"; + + resolver.ResultToReturn = new DfsResolutionResult + { + Status = DfsResolutionStatus.NotApplicable, + ResolvedPath = originalPath, + OriginalPath = originalPath + }; + + DfsAwareClientAdapter adapter = new DfsAwareClientAdapter(inner, resolver, options); + + // Act + object handle; + FileStatus fileStatus; + NTStatus status = adapter.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + // Assert - Should not retry for non-DFS errors + Assert.AreEqual(NTStatus.STATUS_ACCESS_DENIED, status); + Assert.AreEqual(1, inner.CreateFileCallCount, "Should not retry for non-DFS errors"); + } + + #endregion + + /// + /// Helper resolver that returns different paths on each call (simulating interlink resolution). + /// + private class LoopingResolver : IDfsClientResolver + { + private int _callCount = 0; + + public DfsResolutionResult Resolve(DfsClientOptions options, string originalPath) + { + _callCount++; + return new DfsResolutionResult + { + Status = DfsResolutionStatus.Success, + ResolvedPath = originalPath + "_" + _callCount, + OriginalPath = originalPath + }; + } + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsClientFactoryTests.cs b/SMBLibrary.Tests/Client/DfsClientFactoryTests.cs new file mode 100644 index 00000000..11fda23f --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsClientFactoryTests.cs @@ -0,0 +1,152 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsClientFactoryTests + { + private class FakeFileStore : ISMBFileStore + { + public bool CreateFileCalled; + + public NTStatus StatusToReturn = NTStatus.STATUS_SUCCESS; + + public NTStatus CreateFile(out object handle, out FileStatus fileStatus, string path, AccessMask desiredAccess, FileAttributes fileAttributes, ShareAccess shareAccess, CreateDisposition createDisposition, CreateOptions createOptions, SecurityContext securityContext) + { + CreateFileCalled = true; + handle = new object(); + fileStatus = FileStatus.FILE_OPENED; + return StatusToReturn; + } + + public NTStatus CloseFile(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus ReadFile(out byte[] data, object handle, long offset, int maxCount) + { + data = null; + throw new NotImplementedException(); + } + + public NTStatus WriteFile(out int numberOfBytesWritten, object handle, long offset, byte[] data) + { + numberOfBytesWritten = 0; + throw new NotImplementedException(); + } + + public NTStatus FlushFileBuffers(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus LockFile(object handle, long byteOffset, long length, bool exclusiveLock) + { + throw new NotImplementedException(); + } + + public NTStatus UnlockFile(object handle, long byteOffset, long length) + { + throw new NotImplementedException(); + } + + public NTStatus QueryDirectory(out System.Collections.Generic.List result, object handle, string fileName, FileInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus GetFileInformation(out FileInformation result, object handle, FileInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetFileInformation(object handle, FileInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetFileSystemInformation(out FileSystemInformation result, FileSystemInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetFileSystemInformation(FileSystemInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetSecurityInformation(out SecurityDescriptor result, object handle, SecurityInformation securityInformation) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetSecurityInformation(object handle, SecurityInformation securityInformation, SecurityDescriptor securityDescriptor) + { + throw new NotImplementedException(); + } + + public NTStatus NotifyChange(out object ioRequest, object handle, NotifyChangeFilter completionFilter, bool watchTree, int outputBufferSize, OnNotifyChangeCompleted onNotifyChangeCompleted, object context) + { + ioRequest = null; + throw new NotImplementedException(); + } + + public NTStatus Cancel(object ioRequest) + { + throw new NotImplementedException(); + } + + public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) + { + output = null; + throw new NotImplementedException(); + } + + public NTStatus Disconnect() + { + throw new NotImplementedException(); + } + + public uint MaxReadSize + { + get { return 0; } + } + + public uint MaxWriteSize + { + get { return 0; } + } + } + + [TestMethod] + public void CreateDfsAwareFileStore_WhenOptionsNull_ReturnsInnerStore() + { + FakeFileStore inner = new FakeFileStore(); + + ISMBFileStore result = DfsClientFactory.CreateDfsAwareFileStore(inner, null, null); + + Assert.AreSame(inner, result); + } + + [TestMethod] + public void CreateDfsAwareFileStore_WhenDfsDisabled_ReturnsInnerStore() + { + FakeFileStore inner = new FakeFileStore(); + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = false; + + ISMBFileStore result = DfsClientFactory.CreateDfsAwareFileStore(inner, null, options); + + Assert.AreSame(inner, result); + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsClientOptionsTests.cs b/SMBLibrary.Tests/Client/DfsClientOptionsTests.cs new file mode 100644 index 00000000..3b9074d6 --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsClientOptionsTests.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsClientOptionsTests + { + [TestMethod] + public void Ctor_Default_DisablesDfs() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert + Assert.IsFalse(options.Enabled); + } + + [TestMethod] + public void When_EnabledIsSetToTrue_ShouldReflectValue() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); + + // Act + options.Enabled = true; + + // Assert + Assert.IsTrue(options.Enabled); + } + + [TestMethod] + public void Ctor_Default_DisablesDomainCache() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert + Assert.IsFalse(options.EnableDomainCache); + } + + [TestMethod] + public void Ctor_Default_DisablesFullResolution() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert + Assert.IsFalse(options.EnableFullResolution); + } + + [TestMethod] + public void Ctor_Default_DisablesCrossServerSessions() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert + Assert.IsFalse(options.EnableCrossServerSessions); + } + + [TestMethod] + public void Ctor_Default_SetsReferralCacheTtlTo300Seconds() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert - Default TTL of 5 minutes (300 seconds) + Assert.AreEqual(300, options.ReferralCacheTtlSeconds); + } + + [TestMethod] + public void Ctor_Default_SetsDomainCacheTtlTo300Seconds() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert - Default TTL of 5 minutes (300 seconds) + Assert.AreEqual(300, options.DomainCacheTtlSeconds); + } + + [TestMethod] + public void Ctor_Default_SetsMaxRetriesToThree() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert - Reasonable default for retries + Assert.AreEqual(3, options.MaxRetries); + } + + [TestMethod] + public void Ctor_Default_SetsSiteNameToNull() + { + // Arrange / Act + DfsClientOptions options = new DfsClientOptions(); + + // Assert - No site name by default + Assert.IsNull(options.SiteName); + } + + [TestMethod] + public void SiteName_CanBeSet() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); + + // Act + options.SiteName = "Default-First-Site-Name"; + + // Assert + Assert.AreEqual("Default-First-Site-Name", options.SiteName); + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsClientResolverTests.cs b/SMBLibrary.Tests/Client/DfsClientResolverTests.cs new file mode 100644 index 00000000..5409350f --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsClientResolverTests.cs @@ -0,0 +1,303 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsClientResolverTests + { + private class FakeDfsReferralTransport : IDfsReferralTransport + { + public NTStatus StatusToReturn; + public byte[] BufferToReturn; + public uint OutputCountToReturn; + public int CallCount; + + public NTStatus TryGetReferrals(string serverName, string dfsPath, uint maxOutputSize, out byte[] buffer, out uint outputCount) + { + CallCount++; + buffer = BufferToReturn; + outputCount = OutputCountToReturn; + return StatusToReturn; + } + } + + [TestMethod] + public void Resolve_WhenDfsDisabled_ReturnsNotApplicableAndOriginalPath() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); // Enabled is false by default + string originalPath = @"\\server\\share\\path"; + IDfsClientResolver resolver = new DfsClientResolver(); + + // Act + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(originalPath, result.ResolvedPath); + Assert.AreEqual(originalPath, result.OriginalPath); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Resolve_WhenOptionsIsNull_ThrowsArgumentNullException() + { + // Arrange + string originalPath = @"\\server\\share\\path"; + IDfsClientResolver resolver = new DfsClientResolver(); + + // Act + resolver.Resolve(null, originalPath); + } + + [TestMethod] + public void Resolve_WhenDfsEnabledButNotImplemented_ReturnsErrorAndOriginalPath() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + string originalPath = @"\\server\\share\\path"; + IDfsClientResolver resolver = new DfsClientResolver(); + + // Act + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Error, result.Status); + Assert.AreEqual(originalPath, result.ResolvedPath); + Assert.AreEqual(originalPath, result.OriginalPath); + } + + [TestMethod] + public void Resolve_WhenDfsEnabledAndTransportReturnsStatusFsDriverRequired_ReturnsNotApplicableAndOriginalPath() + { + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + string originalPath = "\\\\server\\share\\path"; + + FakeDfsReferralTransport transport = new FakeDfsReferralTransport(); + transport.StatusToReturn = NTStatus.STATUS_FS_DRIVER_REQUIRED; + + IDfsClientResolver resolver = new DfsClientResolver(transport); + + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(originalPath, result.ResolvedPath); + Assert.AreEqual(originalPath, result.OriginalPath); + } + + [TestMethod] + public void Resolve_WhenDfsEnabledAndTransportReturnsErrorStatus_ReturnsErrorAndOriginalPath() + { + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + string originalPath = "\\\\server\\share\\path"; + + FakeDfsReferralTransport transport = new FakeDfsReferralTransport(); + transport.StatusToReturn = NTStatus.STATUS_INVALID_PARAMETER; + + IDfsClientResolver resolver = new DfsClientResolver(transport); + + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + Assert.AreEqual(DfsResolutionStatus.Error, result.Status); + Assert.AreEqual(originalPath, result.ResolvedPath); + Assert.AreEqual(originalPath, result.OriginalPath); + } + + [TestMethod] + public void Resolve_WhenDfsEnabledAndTransportReturnsSuccessWithSingleV2Referral_ReturnsSuccessAndRewrittenPath() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + string dfsPath = "\\\\contoso.com\\Public"; + string originalPath = dfsPath + "\\folder\\file.txt"; + string networkAddress = "\\\\fs1\\Public"; + + // Build a V2 DFSC buffer (V2 has proper offsets for strings) + byte[] dfsPathBytes = System.Text.Encoding.Unicode.GetBytes(dfsPath + "\0"); + byte[] networkAddressBytes = System.Text.Encoding.Unicode.GetBytes(networkAddress + "\0"); + + int entryOffset = 8; + int v2FixedLength = 22; // V2 fixed header size + int dfsPathOffset = v2FixedLength; + int dfsAlternatePathOffset = v2FixedLength + dfsPathBytes.Length; + int networkAddressOffset = dfsAlternatePathOffset + dfsPathBytes.Length; + int entrySize = networkAddressOffset + networkAddressBytes.Length; + + ushort pathConsumed = (ushort)(dfsPath.Length * 2); + int totalSize = 8 + entrySize; + byte[] buffer = new byte[totalSize]; + + // Response header + Utilities.LittleEndianWriter.WriteUInt16(buffer, 0, pathConsumed); + Utilities.LittleEndianWriter.WriteUInt16(buffer, 2, 1); // NumberOfReferrals + Utilities.LittleEndianWriter.WriteUInt32(buffer, 4, 0); // ReferralHeaderFlags + + // V2 entry header + Utilities.LittleEndianWriter.WriteUInt16(buffer, entryOffset + 0, 2); // VersionNumber + Utilities.LittleEndianWriter.WriteUInt16(buffer, entryOffset + 2, (ushort)entrySize); + Utilities.LittleEndianWriter.WriteUInt16(buffer, entryOffset + 4, 0); // ServerType + Utilities.LittleEndianWriter.WriteUInt16(buffer, entryOffset + 6, 0); // ReferralEntryFlags + Utilities.LittleEndianWriter.WriteUInt32(buffer, entryOffset + 8, 0); // Proximity + Utilities.LittleEndianWriter.WriteUInt32(buffer, entryOffset + 12, 300); // TimeToLive + Utilities.LittleEndianWriter.WriteUInt16(buffer, entryOffset + 16, (ushort)dfsPathOffset); + Utilities.LittleEndianWriter.WriteUInt16(buffer, entryOffset + 18, (ushort)dfsAlternatePathOffset); + Utilities.LittleEndianWriter.WriteUInt16(buffer, entryOffset + 20, (ushort)networkAddressOffset); + + // Strings + Array.Copy(dfsPathBytes, 0, buffer, entryOffset + dfsPathOffset, dfsPathBytes.Length); + Array.Copy(dfsPathBytes, 0, buffer, entryOffset + dfsAlternatePathOffset, dfsPathBytes.Length); + Array.Copy(networkAddressBytes, 0, buffer, entryOffset + networkAddressOffset, networkAddressBytes.Length); + + FakeDfsReferralTransport transport = new FakeDfsReferralTransport(); + transport.StatusToReturn = NTStatus.STATUS_SUCCESS; + transport.BufferToReturn = buffer; + transport.OutputCountToReturn = (uint)buffer.Length; + + IDfsClientResolver resolver = new DfsClientResolver(transport); + + // Act + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Success, result.Status); + Assert.AreEqual("\\\\fs1\\Public\\folder\\file.txt", result.ResolvedPath); + Assert.AreEqual(originalPath, result.OriginalPath); + } + + [TestMethod] + public void Resolve_WhenDfsEnabledAndTransportReturnsSuccessWithMultipleV2Referrals_PicksFirstUsableEntry() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + string dfsPath = "\\\\contoso.com\\Public"; + string originalPath = dfsPath + "\\folder\\file.txt"; + string firstNetworkAddress = "\\\\fs1\\Public"; + string secondNetworkAddress = "\\\\fs2\\Public"; + + // Build V2 buffer with 2 entries + SMBLibrary.DFS.ResponseGetDfsReferral response = new SMBLibrary.DFS.ResponseGetDfsReferral(); + response.PathConsumed = (ushort)(dfsPath.Length * 2); + response.ReferralHeaderFlags = SMBLibrary.DFS.DfsReferralHeaderFlags.ReferralServers; + response.ReferralEntries = new System.Collections.Generic.List() + { + new SMBLibrary.DFS.DfsReferralEntryV2() + { + TimeToLive = 300, + DfsPath = dfsPath, + DfsAlternatePath = dfsPath, + NetworkAddress = firstNetworkAddress + }, + new SMBLibrary.DFS.DfsReferralEntryV2() + { + TimeToLive = 300, + DfsPath = dfsPath, + DfsAlternatePath = dfsPath, + NetworkAddress = secondNetworkAddress + } + }; + + FakeDfsReferralTransport transport = new FakeDfsReferralTransport(); + transport.StatusToReturn = NTStatus.STATUS_SUCCESS; + transport.BufferToReturn = response.GetBytes(); + transport.OutputCountToReturn = (uint)transport.BufferToReturn.Length; + + IDfsClientResolver resolver = new DfsClientResolver(transport); + + // Act + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Success, result.Status); + Assert.AreEqual("\\\\fs1\\Public\\folder\\file.txt", result.ResolvedPath); + } + + [TestMethod] + public void Resolve_WhenDfsEnabledAndTransportReturnsSuccessWithEmptyNetworkAddress_ReturnsError() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + string dfsPath = "\\\\contoso.com\\Public"; + string originalPath = dfsPath + "\\folder\\file.txt"; + + // Build V2 buffer with empty network addresses + SMBLibrary.DFS.ResponseGetDfsReferral response = new SMBLibrary.DFS.ResponseGetDfsReferral(); + response.PathConsumed = (ushort)(dfsPath.Length * 2); + response.ReferralHeaderFlags = SMBLibrary.DFS.DfsReferralHeaderFlags.ReferralServers; + response.ReferralEntries = new System.Collections.Generic.List() + { + new SMBLibrary.DFS.DfsReferralEntryV2() + { + TimeToLive = 300, + DfsPath = dfsPath, + DfsAlternatePath = dfsPath, + NetworkAddress = "" + } + }; + + FakeDfsReferralTransport transport = new FakeDfsReferralTransport(); + transport.StatusToReturn = NTStatus.STATUS_SUCCESS; + transport.BufferToReturn = response.GetBytes(); + transport.OutputCountToReturn = (uint)transport.BufferToReturn.Length; + + IDfsClientResolver resolver = new DfsClientResolver(transport); + + // Act + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Error, result.Status); + Assert.AreEqual(originalPath, result.ResolvedPath); + } + + [TestMethod] + public void Resolve_WhenDfsEnabledAndTransportReturnsSuccessWithV1Referral_UsesShareName() + { + // Arrange + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + string dfsPath = "\\\\contoso.com\\Public"; + string originalPath = dfsPath + "\\folder\\file.txt"; + string shareName = "\\\\fs1\\Public"; + + // Build V1 buffer + SMBLibrary.DFS.ResponseGetDfsReferral response = new SMBLibrary.DFS.ResponseGetDfsReferral(); + response.PathConsumed = (ushort)(dfsPath.Length * 2); + response.ReferralHeaderFlags = SMBLibrary.DFS.DfsReferralHeaderFlags.ReferralServers; + response.ReferralEntries = new System.Collections.Generic.List() + { + new SMBLibrary.DFS.DfsReferralEntryV1() + { + ServerType = SMBLibrary.DFS.DfsServerType.Root, + ShareName = shareName + } + }; + + FakeDfsReferralTransport transport = new FakeDfsReferralTransport(); + transport.StatusToReturn = NTStatus.STATUS_SUCCESS; + transport.BufferToReturn = response.GetBytes(); + transport.OutputCountToReturn = (uint)transport.BufferToReturn.Length; + + IDfsClientResolver resolver = new DfsClientResolver(transport); + + // Act + DfsResolutionResult result = resolver.Resolve(options, originalPath); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Success, result.Status); + Assert.AreEqual("\\\\fs1\\Public\\folder\\file.txt", result.ResolvedPath); + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsFileStoreFactoryTests.cs b/SMBLibrary.Tests/Client/DfsFileStoreFactoryTests.cs new file mode 100644 index 00000000..f25649cd --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsFileStoreFactoryTests.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client; +using SMBLibrary.Client.DFS; +using SMBLibrary.SMB2; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsFileStoreFactoryTests + { + private class FakeFileStore : ISMBFileStore + { + public string LastCreatePath; + public object LastDeviceIoControlHandle; + public uint LastCtlCode; + public byte[] LastInput; + public int LastMaxOutputLength; + + public NTStatus CreateStatusToReturn = NTStatus.STATUS_SUCCESS; + public NTStatus DeviceIoControlStatusToReturn = NTStatus.STATUS_FS_DRIVER_REQUIRED; + public byte[] DeviceIoControlOutputToReturn; + + public NTStatus CreateFile(out object handle, out FileStatus fileStatus, string path, AccessMask desiredAccess, FileAttributes fileAttributes, ShareAccess shareAccess, CreateDisposition createDisposition, CreateOptions createOptions, SecurityContext securityContext) + { + LastCreatePath = path; + handle = new object(); + fileStatus = FileStatus.FILE_OPENED; + return CreateStatusToReturn; + } + + public NTStatus CloseFile(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus ReadFile(out byte[] data, object handle, long offset, int maxCount) + { + data = null; + throw new NotImplementedException(); + } + + public NTStatus WriteFile(out int numberOfBytesWritten, object handle, long offset, byte[] data) + { + numberOfBytesWritten = 0; + throw new NotImplementedException(); + } + + public NTStatus FlushFileBuffers(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus LockFile(object handle, long byteOffset, long length, bool exclusiveLock) + { + throw new NotImplementedException(); + } + + public NTStatus UnlockFile(object handle, long byteOffset, long length) + { + throw new NotImplementedException(); + } + + public NTStatus QueryDirectory(out List result, object handle, string fileName, FileInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus GetFileInformation(out FileInformation result, object handle, FileInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetFileInformation(object handle, FileInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetFileSystemInformation(out FileSystemInformation result, FileSystemInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetFileSystemInformation(FileSystemInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetSecurityInformation(out SecurityDescriptor result, object handle, SecurityInformation securityInformation) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetSecurityInformation(object handle, SecurityInformation securityInformation, SecurityDescriptor securityDescriptor) + { + throw new NotImplementedException(); + } + + public NTStatus NotifyChange(out object ioRequest, object handle, NotifyChangeFilter completionFilter, bool watchTree, int outputBufferSize, OnNotifyChangeCompleted onNotifyChangeCompleted, object context) + { + ioRequest = null; + throw new NotImplementedException(); + } + + public NTStatus Cancel(object ioRequest) + { + throw new NotImplementedException(); + } + + public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) + { + LastDeviceIoControlHandle = handle; + LastCtlCode = ctlCode; + LastInput = input; + LastMaxOutputLength = maxOutputLength; + output = DeviceIoControlOutputToReturn; + return DeviceIoControlStatusToReturn; + } + + public NTStatus Disconnect() + { + throw new NotImplementedException(); + } + + public uint MaxReadSize + { + get { return 0; } + } + + public uint MaxWriteSize + { + get { return 0; } + } + } + + [TestMethod] + public void CreateDfsAwareFileStore_WhenServerNotDfsCapable_InvokesDeviceIoControlAndUsesOriginalPath() + { + FakeFileStore inner = new FakeFileStore(); + inner.CreateStatusToReturn = NTStatus.STATUS_SUCCESS; + inner.DeviceIoControlStatusToReturn = NTStatus.STATUS_FS_DRIVER_REQUIRED; + inner.DeviceIoControlOutputToReturn = null; + + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + object dfsHandle = new object(); + INTFileStore dfsAware = DfsFileStoreFactory.CreateDfsAwareFileStore(inner, dfsHandle, options); + + string originalPath = "\\\\server\\share\\path"; + object handle; + FileStatus fileStatus; + NTStatus status = dfsAware.CreateFile(out handle, out fileStatus, originalPath, 0, 0, 0, 0, 0, null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + Assert.AreEqual(originalPath, inner.LastCreatePath); + + Assert.AreEqual(dfsHandle, inner.LastDeviceIoControlHandle); + Assert.AreEqual((uint)IoControlCode.FSCTL_DFS_GET_REFERRALS, inner.LastCtlCode); + Assert.AreEqual(0, inner.LastMaxOutputLength); // we currently pass 0 for maxOutputSize in resolver + Assert.IsNotNull(inner.LastInput); + Assert.IsTrue(inner.LastInput.Length > 0); + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsIoctlRequestBuilderTests.cs b/SMBLibrary.Tests/Client/DfsIoctlRequestBuilderTests.cs new file mode 100644 index 00000000..6f60ac41 --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsIoctlRequestBuilderTests.cs @@ -0,0 +1,120 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client.DFS; +using SMBLibrary.SMB2; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsIoctlRequestBuilderTests + { + [TestMethod] + public void CreateDfsReferralRequest_SetsCtlCodeFileIdAndFlags() + { + // Arrange + string dfsPath = @"\\server\\namespace\\path"; + uint maxOutputResponse = 4096; + + // Act + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(dfsPath, maxOutputResponse); + + // Assert + Assert.AreEqual((uint)IoControlCode.FSCTL_DFS_GET_REFERRALS, request.CtlCode); + Assert.AreEqual(0xFFFFFFFFFFFFFFFFUL, request.FileId.Persistent); + Assert.AreEqual(0xFFFFFFFFFFFFFFFFUL, request.FileId.Volatile); + Assert.IsTrue(request.IsFSCtl); + Assert.AreEqual(maxOutputResponse, request.MaxOutputResponse); + Assert.IsNotNull(request.Input); + Assert.IsTrue(request.Input.Length > 0); + } + + [TestMethod] + [ExpectedException(typeof(System.ArgumentNullException))] + public void CreateDfsReferralRequest_WhenPathIsNull_ThrowsArgumentNullException() + { + // Act + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(null, 4096); + } + + [TestMethod] + public void CreateDfsReferralRequest_SetsMaxReferralLevelToFour() + { + // Arrange + string dfsPath = @"\\server\namespace"; + uint maxOutputResponse = 4096; + + // Act + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(dfsPath, maxOutputResponse); + + // Assert - Parse the input buffer to verify MaxReferralLevel + // The first 2 bytes of the REQ_GET_DFS_REFERRAL buffer are MaxReferralLevel (LE) + ushort maxReferralLevel = (ushort)(request.Input[0] | (request.Input[1] << 8)); + Assert.AreEqual((ushort)4, maxReferralLevel, "MaxReferralLevel should be 4 for maximum V4 referral support"); + } + + [TestMethod] + public void CreateDfsReferralRequestEx_SetsCtlCodeFileIdAndFlags() + { + // Arrange + string dfsPath = @"\\server\namespace\path"; + uint maxOutputResponse = 4096; + + // Act + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequestEx(dfsPath, null, maxOutputResponse); + + // Assert + Assert.AreEqual((uint)IoControlCode.FSCTL_DFS_GET_REFERRALS_EX, request.CtlCode); + Assert.AreEqual(0xFFFFFFFFFFFFFFFFUL, request.FileId.Persistent); + Assert.AreEqual(0xFFFFFFFFFFFFFFFFUL, request.FileId.Volatile); + Assert.IsTrue(request.IsFSCtl); + Assert.AreEqual(maxOutputResponse, request.MaxOutputResponse); + Assert.IsNotNull(request.Input); + Assert.IsTrue(request.Input.Length > 0); + } + + [TestMethod] + [ExpectedException(typeof(System.ArgumentNullException))] + public void CreateDfsReferralRequestEx_WhenPathIsNull_ThrowsArgumentNullException() + { + // Act + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequestEx(null, null, 4096); + } + + [TestMethod] + public void CreateDfsReferralRequestEx_WithSiteName_IncludesSiteNameInRequest() + { + // Arrange + string dfsPath = @"\\server\namespace"; + string siteName = "Default-First-Site-Name"; + uint maxOutputResponse = 4096; + + // Act + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequestEx(dfsPath, siteName, maxOutputResponse); + + // Assert - Request should be larger when site name is included + Assert.IsNotNull(request.Input); + Assert.IsTrue(request.Input.Length > 0); + // The RequestFlags field should indicate site name is present + // RequestFlags is at offset 4 in REQ_GET_DFS_REFERRAL_EX (after MaxReferralLevel[2] and RequestFlags[2]) + ushort requestFlags = (ushort)(request.Input[2] | (request.Input[3] << 8)); + Assert.AreEqual((ushort)1, requestFlags, "RequestFlags should be 1 when SiteName is specified"); + } + + [TestMethod] + public void CreateDfsReferralRequestEx_WithoutSiteName_HasZeroRequestFlags() + { + // Arrange + string dfsPath = @"\\server\namespace"; + uint maxOutputResponse = 4096; + + // Act + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequestEx(dfsPath, null, maxOutputResponse); + + // Assert + Assert.IsNotNull(request.Input); + // RequestFlags at offset 2-3 should be 0 when no site name + ushort requestFlags = (ushort)(request.Input[2] | (request.Input[3] << 8)); + Assert.AreEqual((ushort)0, requestFlags, "RequestFlags should be 0 when no SiteName is specified"); + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsReferralSelectorTests.cs b/SMBLibrary.Tests/Client/DfsReferralSelectorTests.cs new file mode 100644 index 00000000..cdc953af --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsReferralSelectorTests.cs @@ -0,0 +1,119 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.DFS; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsReferralSelectorTests + { + [TestMethod] + public void SelectResolvedPath_SingleV1Referral_RewritesPathUsingShareName() + { + // V1 uses ShareName instead of NetworkAddress + string originalPath = "\\\\contoso.com\\Public\\folder\\file.txt"; + string dfsPath = "\\\\contoso.com\\Public"; + string shareName = "\\\\fs1\\Public"; + + DfsReferralEntryV1 entry = new DfsReferralEntryV1(); + entry.ShareName = shareName; + + ushort pathConsumed = (ushort)(dfsPath.Length * 2); + + string resolvedPath = DfsReferralSelector.SelectResolvedPath(originalPath, pathConsumed, entry); + + Assert.AreEqual("\\\\fs1\\Public\\folder\\file.txt", resolvedPath); + } + + [TestMethod] + public void SelectResolvedPath_V2Referral_RewritesPathUsingNetworkAddress() + { + string originalPath = "\\\\contoso.com\\Public\\folder\\file.txt"; + string dfsPath = "\\\\contoso.com\\Public"; + string networkAddress = "\\\\fs1\\Public"; + + DfsReferralEntryV2 entry = new DfsReferralEntryV2(); + entry.DfsPath = dfsPath; + entry.DfsAlternatePath = dfsPath; + entry.NetworkAddress = networkAddress; + + ushort pathConsumed = (ushort)(dfsPath.Length * 2); + + string resolvedPath = DfsReferralSelector.SelectResolvedPath(originalPath, pathConsumed, entry); + + Assert.AreEqual("\\\\fs1\\Public\\folder\\file.txt", resolvedPath); + } + + [TestMethod] + public void SelectResolvedPath_V3Referral_RewritesPathUsingNetworkAddress() + { + string originalPath = "\\\\contoso.com\\Public"; + string dfsPath = originalPath; + string networkAddress = "\\\\fs1\\Public"; + + DfsReferralEntryV3 entry = new DfsReferralEntryV3(); + entry.DfsPath = dfsPath; + entry.DfsAlternatePath = dfsPath; + entry.NetworkAddress = networkAddress; + + ushort pathConsumed = (ushort)(dfsPath.Length * 2); + + string resolvedPath = DfsReferralSelector.SelectResolvedPath(originalPath, pathConsumed, entry); + + Assert.AreEqual(networkAddress, resolvedPath); + } + + [TestMethod] + public void SelectResolvedPath_MultiReferral_PicksFirstUsableEntry() + { + string originalPath = "\\\\contoso.com\\Public\\folder\\file.txt"; + string dfsPath = "\\\\contoso.com\\Public"; + string firstNetworkAddress = "\\\\fs1\\Public"; + string secondNetworkAddress = "\\\\fs2\\Public"; + + DfsReferralEntryV2 first = new DfsReferralEntryV2(); + first.DfsPath = dfsPath; + first.DfsAlternatePath = dfsPath; + first.NetworkAddress = firstNetworkAddress; + + DfsReferralEntryV2 second = new DfsReferralEntryV2(); + second.DfsPath = dfsPath; + second.DfsAlternatePath = dfsPath; + second.NetworkAddress = secondNetworkAddress; + + DfsReferralEntry[] entries = new DfsReferralEntry[] { first, second }; + + ushort pathConsumed = (ushort)(dfsPath.Length * 2); + + string resolvedPath = DfsReferralSelector.SelectResolvedPath(originalPath, pathConsumed, entries); + + Assert.AreEqual("\\\\fs1\\Public\\folder\\file.txt", resolvedPath); + } + + [TestMethod] + public void SelectResolvedPath_MultiReferral_WhenNoUsableEntry_ReturnsNull() + { + string originalPath = "\\\\contoso.com\\Public\\folder\\file.txt"; + string dfsPath = "\\\\contoso.com\\Public"; + + DfsReferralEntryV2 first = new DfsReferralEntryV2(); + first.DfsPath = dfsPath; + first.DfsAlternatePath = dfsPath; + first.NetworkAddress = null; + + DfsReferralEntryV2 second = new DfsReferralEntryV2(); + second.DfsPath = dfsPath; + second.DfsAlternatePath = dfsPath; + second.NetworkAddress = string.Empty; + + DfsReferralEntry[] entries = new DfsReferralEntry[] { first, second }; + + ushort pathConsumed = (ushort)(dfsPath.Length * 2); + + string resolvedPath = DfsReferralSelector.SelectResolvedPath(originalPath, pathConsumed, entries); + + Assert.IsNull(resolvedPath); + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsReferralTransportTests.cs b/SMBLibrary.Tests/Client/DfsReferralTransportTests.cs new file mode 100644 index 00000000..8b7dc53a --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsReferralTransportTests.cs @@ -0,0 +1,48 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsReferralTransportTests + { + private class FakeDfsReferralTransport : IDfsReferralTransport + { + public NTStatus StatusToReturn; + public byte[] BufferToReturn; + public uint OutputCountToReturn; + + public NTStatus TryGetReferrals(string serverName, string dfsPath, uint maxOutputSize, out byte[] buffer, out uint outputCount) + { + buffer = BufferToReturn; + outputCount = OutputCountToReturn; + return StatusToReturn; + } + } + + [TestMethod] + public void TryGetReferrals_WhenConfigured_ReturnsExpectedStatusAndBuffer() + { + // Arrange + FakeDfsReferralTransport transport = new FakeDfsReferralTransport(); + transport.StatusToReturn = NTStatus.STATUS_SUCCESS; + transport.BufferToReturn = new byte[] { 0x01, 0x02 }; + transport.OutputCountToReturn = 2; + + byte[] buffer; + uint outputCount; + + // Act + NTStatus status = transport.TryGetReferrals("server", "\\\\server\\dfs", 4096, out buffer, out outputCount); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + Assert.IsNotNull(buffer); + Assert.AreEqual(2u, outputCount); + Assert.AreEqual(2, buffer.Length); + Assert.AreEqual(0x01, buffer[0]); + Assert.AreEqual(0x02, buffer[1]); + } + } +} diff --git a/SMBLibrary.Tests/Client/DfsSessionManagerTests.cs b/SMBLibrary.Tests/Client/DfsSessionManagerTests.cs new file mode 100644 index 00000000..b35965b7 --- /dev/null +++ b/SMBLibrary.Tests/Client/DfsSessionManagerTests.cs @@ -0,0 +1,336 @@ +using System; +using System.Net; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class DfsSessionManagerTests + { + #region Constructor Tests + + [TestMethod] + public void Ctor_Default_CreatesInstance() + { + // Act + var manager = new DfsSessionManager(); + + // Assert + Assert.IsNotNull(manager); + } + + [TestMethod] + public void Ctor_WithClientFactory_CreatesInstance() + { + // Arrange + SmbClientFactory factory = delegate(string serverName) { return new FakeSmbClient(); }; + + // Act + var manager = new DfsSessionManager(factory); + + // Assert + Assert.IsNotNull(manager); + } + + #endregion + + #region GetOrCreateSession Tests + + [TestMethod] + public void GetOrCreateSession_NewServer_CreatesNewSession() + { + // Arrange + var helper = new FactoryHelper(true, NTStatus.STATUS_SUCCESS); + var manager = new DfsSessionManager(helper.CreateClient); + var credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + // Act + NTStatus status; + ISMBFileStore store = manager.GetOrCreateSession("server1", "share1", credentials, out status); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + Assert.IsNotNull(store); + Assert.AreEqual(1, helper.ClientsCreated); + } + + [TestMethod] + public void GetOrCreateSession_SameServerDifferentShare_ReusesClient() + { + // Arrange + var helper = new FactoryHelper(true, NTStatus.STATUS_SUCCESS); + var manager = new DfsSessionManager(helper.CreateClient); + var credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + // Act + NTStatus status1; + NTStatus status2; + manager.GetOrCreateSession("server1", "share1", credentials, out status1); + manager.GetOrCreateSession("server1", "share2", credentials, out status2); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status1); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status2); + Assert.AreEqual(1, helper.ClientsCreated, "Should reuse client for same server"); + } + + [TestMethod] + public void GetOrCreateSession_DifferentServers_CreatesSeparateClients() + { + // Arrange + var helper = new FactoryHelper(true, NTStatus.STATUS_SUCCESS); + var manager = new DfsSessionManager(helper.CreateClient); + var credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + // Act + NTStatus status1; + NTStatus status2; + manager.GetOrCreateSession("server1", "share1", credentials, out status1); + manager.GetOrCreateSession("server2", "share1", credentials, out status2); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status1); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status2); + Assert.AreEqual(2, helper.ClientsCreated, "Should create separate clients for different servers"); + } + + [TestMethod] + public void GetOrCreateSession_ConnectionFails_ReturnsError() + { + // Arrange + var helper = new FactoryHelper(false, NTStatus.STATUS_SUCCESS); + var manager = new DfsSessionManager(helper.CreateClient); + var credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + // Act + NTStatus status; + ISMBFileStore store = manager.GetOrCreateSession("server1", "share1", credentials, out status); + + // Assert + Assert.AreEqual(NTStatus.STATUS_BAD_NETWORK_NAME, status); + Assert.IsNull(store); + } + + [TestMethod] + public void GetOrCreateSession_LoginFails_ReturnsError() + { + // Arrange + var helper = new FactoryHelper(true, NTStatus.STATUS_LOGON_FAILURE); + var manager = new DfsSessionManager(helper.CreateClient); + var credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + // Act + NTStatus status; + ISMBFileStore store = manager.GetOrCreateSession("server1", "share1", credentials, out status); + + // Assert + Assert.AreEqual(NTStatus.STATUS_LOGON_FAILURE, status); + Assert.IsNull(store); + } + + [TestMethod] + public void GetOrCreateSession_CaseInsensitiveServerName_ReusesClient() + { + // Arrange + var helper = new FactoryHelper(true, NTStatus.STATUS_SUCCESS); + var manager = new DfsSessionManager(helper.CreateClient); + var credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + // Act + NTStatus status1; + NTStatus status2; + manager.GetOrCreateSession("SERVER1", "share1", credentials, out status1); + manager.GetOrCreateSession("server1", "share2", credentials, out status2); + + // Assert + Assert.AreEqual(1, helper.ClientsCreated, "Should reuse client for case-insensitive server name match"); + } + + #endregion + + #region Dispose Tests + + [TestMethod] + public void Dispose_DisconnectsAllClients() + { + // Arrange + var helper = new MultiClientFactoryHelper(); + var manager = new DfsSessionManager(helper.CreateClient); + var credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + NTStatus ignored; + manager.GetOrCreateSession("server1", "share1", credentials, out ignored); + manager.GetOrCreateSession("server2", "share1", credentials, out ignored); + + // Act + manager.Dispose(); + + // Assert + Assert.IsTrue(helper.Client1.DisconnectCalled, "Client1 should be disconnected"); + Assert.IsTrue(helper.Client2.DisconnectCalled, "Client2 should be disconnected"); + } + + #endregion + + #region Helper Classes + + /// + /// Helper for creating fake SMB clients with configurable behavior. + /// + private class FactoryHelper + { + private readonly bool _connectResult; + private readonly NTStatus _loginResult; + + public int ClientsCreated { get; private set; } + + public FactoryHelper(bool connectResult, NTStatus loginResult) + { + _connectResult = connectResult; + _loginResult = loginResult; + } + + public ISMBClient CreateClient(string serverName) + { + ClientsCreated++; + FakeSmbClient client = new FakeSmbClient(); + client.ConnectResult = _connectResult; + client.LoginResult = _loginResult; + return client; + } + } + + /// + /// Helper for tracking multiple clients across different servers. + /// + private class MultiClientFactoryHelper + { + private int _factoryCalls; + + public FakeSmbClient Client1 { get; private set; } + public FakeSmbClient Client2 { get; private set; } + + public MultiClientFactoryHelper() + { + Client1 = new FakeSmbClient(); + Client1.ConnectResult = true; + Client1.LoginResult = NTStatus.STATUS_SUCCESS; + Client2 = new FakeSmbClient(); + Client2.ConnectResult = true; + Client2.LoginResult = NTStatus.STATUS_SUCCESS; + } + + public ISMBClient CreateClient(string serverName) + { + _factoryCalls++; + return _factoryCalls == 1 ? Client1 : Client2; + } + } + + /// + /// Fake ISMBClient for testing without network. + /// + private class FakeSmbClient : ISMBClient + { + public bool ConnectResult { get; set; } + public NTStatus LoginResult { get; set; } = NTStatus.STATUS_SUCCESS; + public NTStatus TreeConnectResult { get; set; } = NTStatus.STATUS_SUCCESS; + public bool DisconnectCalled { get; private set; } + + public bool Connect(string serverName, SMBTransportType transport) + { + return ConnectResult; + } + + public bool Connect(IPAddress serverAddress, SMBTransportType transport) + { + return ConnectResult; + } + + public void Disconnect() + { + DisconnectCalled = true; + } + + public NTStatus Login(string domainName, string userName, string password) + { + return LoginResult; + } + + public NTStatus Login(string domainName, string userName, string password, AuthenticationMethod authenticationMethod) + { + return LoginResult; + } + + public NTStatus Logoff() + { + return NTStatus.STATUS_SUCCESS; + } + + public System.Collections.Generic.List ListShares(out NTStatus status) + { + status = NTStatus.STATUS_SUCCESS; + return new System.Collections.Generic.List(); + } + + public ISMBFileStore TreeConnect(string shareName, out NTStatus status) + { + status = TreeConnectResult; + if (status == NTStatus.STATUS_SUCCESS) + { + return new FakeSmbFileStore(); + } + return null; + } + + public NTStatus Echo() + { + return NTStatus.STATUS_SUCCESS; + } + + public uint MaxReadSize => 65536; + + public uint MaxWriteSize => 65536; + + public bool IsConnected => ConnectResult; + } + + /// + /// Minimal fake ISMBFileStore for testing. + /// + private class FakeSmbFileStore : ISMBFileStore + { + public NTStatus CreateFile(out object handle, out FileStatus fileStatus, string path, AccessMask desiredAccess, FileAttributes fileAttributes, ShareAccess shareAccess, CreateDisposition createDisposition, CreateOptions createOptions, SecurityContext securityContext) + { + handle = new object(); + fileStatus = FileStatus.FILE_CREATED; + return NTStatus.STATUS_SUCCESS; + } + + public NTStatus CloseFile(object handle) => NTStatus.STATUS_SUCCESS; + public NTStatus ReadFile(out byte[] data, object handle, long offset, int maxCount) { data = new byte[0]; return NTStatus.STATUS_SUCCESS; } + public NTStatus WriteFile(out int numberOfBytesWritten, object handle, long offset, byte[] data) { numberOfBytesWritten = 0; return NTStatus.STATUS_SUCCESS; } + public NTStatus FlushFileBuffers(object handle) => NTStatus.STATUS_SUCCESS; + public NTStatus LockFile(object handle, long byteOffset, long length, bool exclusiveLock) => NTStatus.STATUS_SUCCESS; + public NTStatus UnlockFile(object handle, long byteOffset, long length) => NTStatus.STATUS_SUCCESS; + public NTStatus QueryDirectory(out System.Collections.Generic.List result, object handle, string fileName, FileInformationClass informationClass) { result = new System.Collections.Generic.List(); return NTStatus.STATUS_SUCCESS; } + public NTStatus GetFileInformation(out FileInformation result, object handle, FileInformationClass informationClass) { result = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus SetFileInformation(object handle, FileInformation information) => NTStatus.STATUS_SUCCESS; + public NTStatus GetFileSystemInformation(out FileSystemInformation result, FileSystemInformationClass informationClass) { result = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus SetFileSystemInformation(FileSystemInformation information) => NTStatus.STATUS_SUCCESS; + public NTStatus GetSecurityInformation(out SecurityDescriptor result, object handle, SecurityInformation securityInformation) { result = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus SetSecurityInformation(object handle, SecurityInformation securityInformation, SecurityDescriptor securityDescriptor) => NTStatus.STATUS_SUCCESS; + public NTStatus NotifyChange(out object ioRequest, object handle, NotifyChangeFilter completionFilter, bool watchTree, int outputBufferSize, OnNotifyChangeCompleted onNotifyChangeCompleted, object context) { ioRequest = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus Cancel(object ioRequest) => NTStatus.STATUS_SUCCESS; + public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) { output = new byte[0]; return NTStatus.STATUS_SUCCESS; } + public NTStatus Disconnect() => NTStatus.STATUS_SUCCESS; + public uint MaxReadSize => 65536; + public uint MaxWriteSize => 65536; + } + + #endregion + } +} diff --git a/SMBLibrary.Tests/Client/SMB2ClientDfsPropertyTests.cs b/SMBLibrary.Tests/Client/SMB2ClientDfsPropertyTests.cs new file mode 100644 index 00000000..2a948eea --- /dev/null +++ b/SMBLibrary.Tests/Client/SMB2ClientDfsPropertyTests.cs @@ -0,0 +1,49 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class SMB2ClientDfsPropertyTests + { + [TestMethod] + public void DfsClientOptions_DefaultsToNull() + { + SMB2Client client = new SMB2Client(); + + Assert.IsNull(client.DfsClientOptions); + } + + [TestMethod] + public void DfsClientOptions_CanBeSetAndRead() + { + SMB2Client client = new SMB2Client(); + DfsClientOptions options = new DfsClientOptions { Enabled = true }; + + client.DfsClientOptions = options; + + Assert.AreSame(options, client.DfsClientOptions); + Assert.IsTrue(client.DfsClientOptions.Enabled); + } + + [TestMethod] + public void DfsClientOptions_CanBeSetToNull() + { + SMB2Client client = new SMB2Client(); + client.DfsClientOptions = new DfsClientOptions { Enabled = true }; + + client.DfsClientOptions = null; + + Assert.IsNull(client.DfsClientOptions); + } + + [TestMethod] + public void DfsClientOptions_EnabledDefaultsFalse() + { + DfsClientOptions options = new DfsClientOptions(); + + Assert.IsFalse(options.Enabled); + } + } +} diff --git a/SMBLibrary.Tests/Client/Smb2DfsReferralTransportTests.cs b/SMBLibrary.Tests/Client/Smb2DfsReferralTransportTests.cs new file mode 100644 index 00000000..6864f295 --- /dev/null +++ b/SMBLibrary.Tests/Client/Smb2DfsReferralTransportTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client.DFS; +using SMBLibrary.SMB2; + +namespace SMBLibrary.Tests.Client +{ + [TestClass] + public class Smb2DfsReferralTransportTests + { + private class FakeFileStore : INTFileStore + { + public object LastHandle; + public uint LastCtlCode; + public byte[] LastInput; + public int LastMaxOutputLength; + public NTStatus StatusToReturn = NTStatus.STATUS_SUCCESS; + public byte[] OutputToReturn; + + public NTStatus CreateFile(out object handle, out FileStatus fileStatus, string path, AccessMask desiredAccess, FileAttributes fileAttributes, ShareAccess shareAccess, CreateDisposition createDisposition, CreateOptions createOptions, SecurityContext securityContext) + { + handle = null; + fileStatus = FileStatus.FILE_DOES_NOT_EXIST; + throw new NotImplementedException(); + } + + public NTStatus CloseFile(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus ReadFile(out byte[] data, object handle, long offset, int maxCount) + { + data = null; + throw new NotImplementedException(); + } + + public NTStatus WriteFile(out int numberOfBytesWritten, object handle, long offset, byte[] data) + { + numberOfBytesWritten = 0; + throw new NotImplementedException(); + } + + public NTStatus FlushFileBuffers(object handle) + { + throw new NotImplementedException(); + } + + public NTStatus LockFile(object handle, long byteOffset, long length, bool exclusiveLock) + { + throw new NotImplementedException(); + } + + public NTStatus UnlockFile(object handle, long byteOffset, long length) + { + throw new NotImplementedException(); + } + + public NTStatus QueryDirectory(out List result, object handle, string fileName, FileInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus GetFileInformation(out FileInformation result, object handle, FileInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetFileInformation(object handle, FileInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetFileSystemInformation(out FileSystemInformation result, FileSystemInformationClass informationClass) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetFileSystemInformation(FileSystemInformation information) + { + throw new NotImplementedException(); + } + + public NTStatus GetSecurityInformation(out SecurityDescriptor result, object handle, SecurityInformation securityInformation) + { + result = null; + throw new NotImplementedException(); + } + + public NTStatus SetSecurityInformation(object handle, SecurityInformation securityInformation, SecurityDescriptor securityDescriptor) + { + throw new NotImplementedException(); + } + + public NTStatus NotifyChange(out object ioRequest, object handle, NotifyChangeFilter completionFilter, bool watchTree, int outputBufferSize, OnNotifyChangeCompleted onNotifyChangeCompleted, object context) + { + ioRequest = null; + throw new NotImplementedException(); + } + + public NTStatus Cancel(object ioRequest) + { + throw new NotImplementedException(); + } + + public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) + { + LastHandle = handle; + LastCtlCode = ctlCode; + LastInput = input; + LastMaxOutputLength = maxOutputLength; + output = OutputToReturn; + return StatusToReturn; + } + } + + private class CapturingIoctlSender + { + public IOCtlRequest LastRequest; + public NTStatus StatusToReturn; + public byte[] OutputToReturn; + public uint OutputCountToReturn; + + public NTStatus Send(IOCtlRequest request, out byte[] output, out uint outputCount) + { + LastRequest = request; + output = OutputToReturn; + outputCount = OutputCountToReturn; + return StatusToReturn; + } + } + + [TestMethod] + public void TryGetReferrals_UsesSenderAndReturnsStatusAndBuffer() + { + string dfsPath = "\\\\contoso.com\\Public"; + uint maxOutputSize = 4096; + + CapturingIoctlSender sender = new CapturingIoctlSender(); + sender.StatusToReturn = NTStatus.STATUS_SUCCESS; + sender.OutputToReturn = new byte[] { 0x01, 0x02, 0x03 }; + sender.OutputCountToReturn = 3; + + Smb2DfsReferralTransport transport = new Smb2DfsReferralTransport(sender.Send); + + byte[] buffer; + uint outputCount; + NTStatus status = transport.TryGetReferrals("server", dfsPath, maxOutputSize, out buffer, out outputCount); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + Assert.IsNotNull(buffer); + Assert.AreEqual(3u, outputCount); + Assert.AreEqual(3, buffer.Length); + Assert.AreEqual(0x01, buffer[0]); + Assert.AreEqual(0x02, buffer[1]); + Assert.AreEqual(0x03, buffer[2]); + + Assert.IsNotNull(sender.LastRequest); + Assert.AreEqual((uint)IoControlCode.FSCTL_DFS_GET_REFERRALS, sender.LastRequest.CtlCode); + Assert.IsTrue(sender.LastRequest.IsFSCtl); + Assert.AreEqual(maxOutputSize, sender.LastRequest.MaxOutputResponse); + Assert.AreEqual(0xFFFFFFFFFFFFFFFFUL, sender.LastRequest.FileId.Persistent); + Assert.AreEqual(0xFFFFFFFFFFFFFFFFUL, sender.LastRequest.FileId.Volatile); + Assert.IsNotNull(sender.LastRequest.Input); + Assert.IsTrue(sender.LastRequest.Input.Length > 0); + } + + [TestMethod] + public void CreateUsingDeviceIOControl_UsesDeviceIoControlAndReturnsStatusAndBuffer() + { + string dfsPath = "\\\\contoso.com\\Public"; + uint maxOutputSize = 4096; + + FakeFileStore fileStore = new FakeFileStore(); + fileStore.StatusToReturn = NTStatus.STATUS_SUCCESS; + fileStore.OutputToReturn = new byte[] { 0x10, 0x20 }; + object handle = new object(); + + IDfsReferralTransport transport = Smb2DfsReferralTransport.CreateUsingDeviceIOControl(fileStore, handle); + + byte[] buffer; + uint outputCount; + NTStatus status = transport.TryGetReferrals("server", dfsPath, maxOutputSize, out buffer, out outputCount); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + Assert.IsNotNull(buffer); + Assert.AreEqual(2u, outputCount); + Assert.AreEqual(2, buffer.Length); + Assert.AreEqual(0x10, buffer[0]); + Assert.AreEqual(0x20, buffer[1]); + + Assert.AreEqual(handle, fileStore.LastHandle); + Assert.AreEqual((uint)IoControlCode.FSCTL_DFS_GET_REFERRALS, fileStore.LastCtlCode); + Assert.AreEqual((int)maxOutputSize, fileStore.LastMaxOutputLength); + Assert.IsNotNull(fileStore.LastInput); + Assert.IsTrue(fileStore.LastInput.Length > 0); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DfsEventsTests.cs b/SMBLibrary.Tests/DFS/DfsEventsTests.cs new file mode 100644 index 00000000..82f782c5 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsEventsTests.cs @@ -0,0 +1,155 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// Unit tests for DFS event args classes used for observability. + /// + [TestClass] + public class DfsEventsTests + { + #region DfsResolutionStartedEventArgs + + [TestMethod] + public void DfsResolutionStartedEventArgs_Ctor_SetsPath() + { + // Arrange & Act + var args = new DfsResolutionStartedEventArgs(@"\\domain\share\folder"); + + // Assert + Assert.AreEqual(@"\\domain\share\folder", args.Path); + } + + [TestMethod] + public void DfsResolutionStartedEventArgs_IsEventArgs() + { + // Arrange & Act + var args = new DfsResolutionStartedEventArgs(@"\\domain\share"); + + // Assert + Assert.IsInstanceOfType(args, typeof(EventArgs)); + } + + #endregion + + #region DfsReferralRequestedEventArgs + + [TestMethod] + public void DfsReferralRequestedEventArgs_Ctor_SetsProperties() + { + // Arrange & Act + var args = new DfsReferralRequestedEventArgs( + @"\\domain\share", + DfsRequestType.RootReferral, + "server.domain.com"); + + // Assert + Assert.AreEqual(@"\\domain\share", args.Path); + Assert.AreEqual(DfsRequestType.RootReferral, args.RequestType); + Assert.AreEqual("server.domain.com", args.TargetServer); + } + + [TestMethod] + public void DfsReferralRequestedEventArgs_TargetServer_CanBeNull() + { + // Arrange & Act + var args = new DfsReferralRequestedEventArgs( + @"\\domain\share", + DfsRequestType.DomainReferral, + null); + + // Assert + Assert.IsNull(args.TargetServer); + } + + #endregion + + #region DfsReferralReceivedEventArgs + + [TestMethod] + public void DfsReferralReceivedEventArgs_Ctor_SetsProperties() + { + // Arrange & Act + var args = new DfsReferralReceivedEventArgs( + @"\\domain\share", + NTStatus.STATUS_SUCCESS, + 3, + 300); + + // Assert + Assert.AreEqual(@"\\domain\share", args.Path); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, args.Status); + Assert.AreEqual(3, args.ReferralCount); + Assert.AreEqual(300, args.TtlSeconds); + } + + [TestMethod] + public void DfsReferralReceivedEventArgs_WithError_HasZeroReferrals() + { + // Arrange & Act + var args = new DfsReferralReceivedEventArgs( + @"\\domain\share", + NTStatus.STATUS_NOT_FOUND, + 0, + 0); + + // Assert + Assert.AreEqual(NTStatus.STATUS_NOT_FOUND, args.Status); + Assert.AreEqual(0, args.ReferralCount); + } + + #endregion + + #region DfsResolutionCompletedEventArgs + + [TestMethod] + public void DfsResolutionCompletedEventArgs_Ctor_SetsProperties() + { + // Arrange & Act + var args = new DfsResolutionCompletedEventArgs( + @"\\domain\share\folder", + @"\\server1\share\folder", + NTStatus.STATUS_SUCCESS, + true); + + // Assert + Assert.AreEqual(@"\\domain\share\folder", args.OriginalPath); + Assert.AreEqual(@"\\server1\share\folder", args.ResolvedPath); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, args.Status); + Assert.IsTrue(args.WasDfsPath); + } + + [TestMethod] + public void DfsResolutionCompletedEventArgs_NonDfsPath_SetsWasDfsFalse() + { + // Arrange & Act + var args = new DfsResolutionCompletedEventArgs( + @"\\server\share", + @"\\server\share", + NTStatus.STATUS_SUCCESS, + false); + + // Assert + Assert.IsFalse(args.WasDfsPath); + Assert.AreEqual(args.OriginalPath, args.ResolvedPath); + } + + [TestMethod] + public void DfsResolutionCompletedEventArgs_WithError_HasOriginalAsResolved() + { + // Arrange & Act + var args = new DfsResolutionCompletedEventArgs( + @"\\domain\share", + @"\\domain\share", + NTStatus.STATUS_PATH_NOT_COVERED, + true); + + // Assert + Assert.AreEqual(NTStatus.STATUS_PATH_NOT_COVERED, args.Status); + } + + #endregion + } +} diff --git a/SMBLibrary.Tests/DFS/DfsExceptionTests.cs b/SMBLibrary.Tests/DFS/DfsExceptionTests.cs new file mode 100644 index 00000000..d91f55c7 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsExceptionTests.cs @@ -0,0 +1,90 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// Unit tests for DfsException class for DFS resolution errors. + /// + [TestClass] + public class DfsExceptionTests + { + [TestMethod] + public void Ctor_WithMessage_SetsMessage() + { + // Arrange & Act + var ex = new DfsException("DFS resolution failed"); + + // Assert + Assert.AreEqual("DFS resolution failed", ex.Message); + } + + [TestMethod] + public void Ctor_WithMessageAndInner_SetsMessageAndInnerException() + { + // Arrange + var inner = new InvalidOperationException("inner"); + + // Act + var ex = new DfsException("DFS resolution failed", inner); + + // Assert + Assert.AreEqual("DFS resolution failed", ex.Message); + Assert.AreSame(inner, ex.InnerException); + } + + [TestMethod] + public void Ctor_WithMessageAndStatus_SetsMessageAndStatus() + { + // Arrange & Act + var ex = new DfsException("DFS resolution failed", NTStatus.STATUS_PATH_NOT_COVERED); + + // Assert + Assert.AreEqual("DFS resolution failed", ex.Message); + Assert.AreEqual(NTStatus.STATUS_PATH_NOT_COVERED, ex.Status); + } + + [TestMethod] + public void Ctor_WithMessageStatusAndPath_SetsAllProperties() + { + // Arrange & Act + var ex = new DfsException("DFS resolution failed", NTStatus.STATUS_PATH_NOT_COVERED, @"\\domain\share\path"); + + // Assert + Assert.AreEqual("DFS resolution failed", ex.Message); + Assert.AreEqual(NTStatus.STATUS_PATH_NOT_COVERED, ex.Status); + Assert.AreEqual(@"\\domain\share\path", ex.Path); + } + + [TestMethod] + public void Status_DefaultIsSuccess() + { + // Arrange & Act + var ex = new DfsException("test"); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ex.Status); + } + + [TestMethod] + public void Path_DefaultIsNull() + { + // Arrange & Act + var ex = new DfsException("test"); + + // Assert + Assert.IsNull(ex.Path); + } + + [TestMethod] + public void DfsException_IsException() + { + // Arrange & Act + var ex = new DfsException("test"); + + // Assert + Assert.IsInstanceOfType(ex, typeof(Exception)); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DfsIntegrationTests.cs b/SMBLibrary.Tests/DFS/DfsIntegrationTests.cs new file mode 100644 index 00000000..9c4a3db9 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsIntegrationTests.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// Integration tests for DFS client functionality. + /// These tests validate end-to-end DFS resolution flows using fakes. + /// + [TestClass] + public class DfsIntegrationTests + { + #region DfsClientFactory Integration Tests + + [TestMethod] + public void DfsClientFactory_WhenDfsEnabled_ReturnsWrappedStore() + { + // Arrange + FakeFileStore innerStore = new FakeFileStore(); + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + // Act + ISMBFileStore result = DfsClientFactory.CreateDfsAwareFileStore(innerStore, null, options); + + // Assert + Assert.IsNotNull(result); + Assert.AreNotSame(innerStore, result, "Should return a wrapped store when DFS is enabled"); + } + + [TestMethod] + public void DfsClientFactory_WhenDfsDisabled_ReturnsOriginalStore() + { + // Arrange + FakeFileStore innerStore = new FakeFileStore(); + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = false; + + // Act + ISMBFileStore result = DfsClientFactory.CreateDfsAwareFileStore(innerStore, null, options); + + // Assert + Assert.AreSame(innerStore, result, "Should return original store when DFS is disabled"); + } + + [TestMethod] + public void DfsClientFactory_WhenOptionsNull_ReturnsOriginalStore() + { + // Arrange + FakeFileStore innerStore = new FakeFileStore(); + + // Act + ISMBFileStore result = DfsClientFactory.CreateDfsAwareFileStore(innerStore, null, null); + + // Assert + Assert.AreSame(innerStore, result); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void DfsClientFactory_WhenInnerStoreNull_ThrowsArgumentNullException() + { + // Act + DfsClientFactory.CreateDfsAwareFileStore(null, null, new DfsClientOptions()); + } + + #endregion + + #region DfsPathResolver End-to-End Tests + + [TestMethod] + public void DfsPathResolver_EndToEnd_CachesAndReusesReferrals() + { + // Arrange - Pre-populate cache to test cache hit path + ReferralCache cache = new ReferralCache(); + DomainCache domainCache = new DomainCache(); + + // Create a cache entry for the path prefix + SMBLibrary.ReferralCacheEntry cacheEntry = new SMBLibrary.ReferralCacheEntry(@"\\server\share"); + cacheEntry.TtlSeconds = 300; + cacheEntry.ExpiresUtc = DateTime.UtcNow.AddSeconds(300); + cacheEntry.TargetList.Add(new TargetSetEntry(@"\\target\share")); + cache.Add(cacheEntry); + + int transportCallCount = 0; + FakeTransport transport = new FakeTransport(delegate(string server, string path, uint flags, out byte[] buffer, out uint outputCount) + { + transportCallCount++; + buffer = null; + outputCount = 0; + return NTStatus.STATUS_SUCCESS; + }); + + DfsPathResolver resolver = new DfsPathResolver(cache, domainCache, transport); + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + // Act - Both resolutions should use cached entry (prefix match) + DfsResolutionResult result1 = resolver.Resolve(options, @"\\server\share\file.txt"); + DfsResolutionResult result2 = resolver.Resolve(options, @"\\server\share\other.txt"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Success, result1.Status); + Assert.AreEqual(DfsResolutionStatus.Success, result2.Status); + Assert.AreEqual(0, transportCallCount, "Should not call transport when cache has matching entry"); + } + + [TestMethod] + public void DfsPathResolver_EndToEnd_HandlesServerNotDfsCapable() + { + // Arrange + ReferralCache cache = new ReferralCache(); + DomainCache domainCache = new DomainCache(); + FakeTransport transport = new FakeTransport(delegate(string server, string path, uint flags, out byte[] buffer, out uint outputCount) + { + buffer = null; + outputCount = 0; + return NTStatus.STATUS_FS_DRIVER_REQUIRED; + }); + + DfsPathResolver resolver = new DfsPathResolver(cache, domainCache, transport); + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + // Act + DfsResolutionResult result = resolver.Resolve(options, @"\\server\share\file.txt"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(@"\\server\share\file.txt", result.ResolvedPath); + Assert.IsFalse(result.IsDfsPath); + } + + [TestMethod] + public void DfsPathResolver_EndToEnd_SkipsIpcPaths() + { + // Arrange + ReferralCache cache = new ReferralCache(); + DomainCache domainCache = new DomainCache(); + int transportCallCount = 0; + FakeTransport transport = new FakeTransport(delegate(string server, string path, uint flags, out byte[] buffer, out uint outputCount) + { + transportCallCount++; + buffer = null; + outputCount = 0; + return NTStatus.STATUS_SUCCESS; + }); + + DfsPathResolver resolver = new DfsPathResolver(cache, domainCache, transport); + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + // Act + DfsResolutionResult result = resolver.Resolve(options, @"\\server\IPC$"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(0, transportCallCount, "Should not call transport for IPC$ paths"); + } + + [TestMethod] + public void DfsPathResolver_EndToEnd_RaisesAllEvents() + { + // Arrange + ReferralCache cache = new ReferralCache(); + DomainCache domainCache = new DomainCache(); + FakeTransport transport = new FakeTransport(delegate(string server, string path, uint flags, out byte[] buffer, out uint outputCount) + { + buffer = BuildV1ReferralResponse(@"\\server\share", @"\\target\share", 300); + outputCount = (uint)buffer.Length; + return NTStatus.STATUS_SUCCESS; + }); + + DfsPathResolver resolver = new DfsPathResolver(cache, domainCache, transport); + DfsClientOptions options = new DfsClientOptions(); + options.Enabled = true; + + bool startedRaised = false; + bool requestedRaised = false; + bool receivedRaised = false; + bool completedRaised = false; + + resolver.ResolutionStarted += (s, e) => startedRaised = true; + resolver.ReferralRequested += (s, e) => requestedRaised = true; + resolver.ReferralReceived += (s, e) => receivedRaised = true; + resolver.ResolutionCompleted += (s, e) => completedRaised = true; + + // Act + resolver.Resolve(options, @"\\server\share\file.txt"); + + // Assert + Assert.IsTrue(startedRaised, "ResolutionStarted should be raised"); + Assert.IsTrue(requestedRaised, "ReferralRequested should be raised"); + Assert.IsTrue(receivedRaised, "ReferralReceived should be raised"); + Assert.IsTrue(completedRaised, "ResolutionCompleted should be raised"); + } + + #endregion + + #region DfsSessionManager Integration Tests + + [TestMethod] + public void DfsSessionManager_EndToEnd_ManagesMultipleServers() + { + // Arrange + List connectedServers = new List(); + SmbClientFactory factory = delegate(string serverName) + { + connectedServers.Add(serverName); + FakeSmbClient client = new FakeSmbClient(); + client.ConnectResult = true; + client.LoginResult = NTStatus.STATUS_SUCCESS; + return client; + }; + + DfsSessionManager manager = new DfsSessionManager(factory); + DfsCredentials credentials = new DfsCredentials("DOMAIN", "user", "pass"); + + // Act + NTStatus status1; + NTStatus status2; + NTStatus status3; + manager.GetOrCreateSession("server1", "share1", credentials, out status1); + manager.GetOrCreateSession("server2", "share1", credentials, out status2); + manager.GetOrCreateSession("server1", "share2", credentials, out status3); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status1); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status2); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status3); + Assert.AreEqual(2, connectedServers.Count, "Should only connect to 2 unique servers"); + Assert.IsTrue(connectedServers.Contains("server1")); + Assert.IsTrue(connectedServers.Contains("server2")); + } + + #endregion + + #region Helper Methods + + private static byte[] BuildV1ReferralResponse(string dfsPath, string networkAddress, uint ttl) + { + // Build a minimal V1 referral response + // Header: PathConsumed(2) + NumberOfReferrals(2) + HeaderFlags(4) = 8 bytes + // V1 Entry: VersionNumber(2) + Size(2) + ServerType(2) + ReferralEntryFlags(2) + + // Reserved(4) + DfsPathOffset(2) + NetworkAddressOffset(2) + TimeToLive(4) = 20 bytes + // Strings follow after entry + + byte[] dfsPathBytes = System.Text.Encoding.Unicode.GetBytes(dfsPath + "\0"); + byte[] networkAddressBytes = System.Text.Encoding.Unicode.GetBytes(networkAddress + "\0"); + + int headerSize = 8; + int entrySize = 20; + int stringsOffset = headerSize + entrySize; + int totalSize = stringsOffset + dfsPathBytes.Length + networkAddressBytes.Length; + + byte[] buffer = new byte[totalSize]; + + // Header + ushort pathConsumed = (ushort)(dfsPath.Length * 2); + buffer[0] = (byte)(pathConsumed & 0xFF); + buffer[1] = (byte)((pathConsumed >> 8) & 0xFF); + buffer[2] = 1; // NumberOfReferrals = 1 + buffer[3] = 0; + // HeaderFlags = 0 + buffer[4] = 0; + buffer[5] = 0; + buffer[6] = 0; + buffer[7] = 0; + + // V1 Entry + int entryOffset = headerSize; + buffer[entryOffset + 0] = 1; // VersionNumber = 1 + buffer[entryOffset + 1] = 0; + buffer[entryOffset + 2] = (byte)(entrySize & 0xFF); // Size + buffer[entryOffset + 3] = (byte)((entrySize >> 8) & 0xFF); + buffer[entryOffset + 4] = 0; // ServerType = 0 (NonRoot) + buffer[entryOffset + 5] = 0; + buffer[entryOffset + 6] = 0; // ReferralEntryFlags = 0 + buffer[entryOffset + 7] = 0; + // Reserved (4 bytes) = 0 + + // DfsPathOffset (relative to entry start) + ushort dfsPathOffset = (ushort)(entrySize); + buffer[entryOffset + 12] = (byte)(dfsPathOffset & 0xFF); + buffer[entryOffset + 13] = (byte)((dfsPathOffset >> 8) & 0xFF); + + // NetworkAddressOffset (relative to entry start) + ushort networkAddressOffset = (ushort)(entrySize + dfsPathBytes.Length); + buffer[entryOffset + 14] = (byte)(networkAddressOffset & 0xFF); + buffer[entryOffset + 15] = (byte)((networkAddressOffset >> 8) & 0xFF); + + // TimeToLive + buffer[entryOffset + 16] = (byte)(ttl & 0xFF); + buffer[entryOffset + 17] = (byte)((ttl >> 8) & 0xFF); + buffer[entryOffset + 18] = (byte)((ttl >> 16) & 0xFF); + buffer[entryOffset + 19] = (byte)((ttl >> 24) & 0xFF); + + // Strings + Array.Copy(dfsPathBytes, 0, buffer, stringsOffset, dfsPathBytes.Length); + Array.Copy(networkAddressBytes, 0, buffer, stringsOffset + dfsPathBytes.Length, networkAddressBytes.Length); + + return buffer; + } + + #endregion + + #region Helper Classes + + private delegate NTStatus TransportDelegate(string serverName, string path, uint flags, out byte[] buffer, out uint outputCount); + + private class FakeTransport : IDfsReferralTransport + { + private readonly TransportDelegate _handler; + + public FakeTransport(TransportDelegate handler) + { + _handler = handler; + } + + public NTStatus TryGetReferrals(string serverName, string path, uint flags, out byte[] buffer, out uint outputCount) + { + return _handler(serverName, path, flags, out buffer, out outputCount); + } + } + + private class FakeFileStore : ISMBFileStore + { + public NTStatus DeviceIoControlStatus = NTStatus.STATUS_FS_DRIVER_REQUIRED; + public byte[] DeviceIoControlOutput = null; + + public NTStatus CreateFile(out object handle, out FileStatus fileStatus, string path, AccessMask desiredAccess, FileAttributes fileAttributes, ShareAccess shareAccess, CreateDisposition createDisposition, CreateOptions createOptions, SecurityContext securityContext) + { + handle = new object(); + fileStatus = FileStatus.FILE_OPENED; + return NTStatus.STATUS_SUCCESS; + } + + public NTStatus CloseFile(object handle) { return NTStatus.STATUS_SUCCESS; } + public NTStatus ReadFile(out byte[] data, object handle, long offset, int maxCount) { data = new byte[0]; return NTStatus.STATUS_SUCCESS; } + public NTStatus WriteFile(out int numberOfBytesWritten, object handle, long offset, byte[] data) { numberOfBytesWritten = 0; return NTStatus.STATUS_SUCCESS; } + public NTStatus FlushFileBuffers(object handle) { return NTStatus.STATUS_SUCCESS; } + public NTStatus LockFile(object handle, long byteOffset, long length, bool exclusiveLock) { return NTStatus.STATUS_SUCCESS; } + public NTStatus UnlockFile(object handle, long byteOffset, long length) { return NTStatus.STATUS_SUCCESS; } + public NTStatus QueryDirectory(out List result, object handle, string fileName, FileInformationClass informationClass) { result = new List(); return NTStatus.STATUS_SUCCESS; } + public NTStatus GetFileInformation(out FileInformation result, object handle, FileInformationClass informationClass) { result = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus SetFileInformation(object handle, FileInformation information) { return NTStatus.STATUS_SUCCESS; } + public NTStatus GetFileSystemInformation(out FileSystemInformation result, FileSystemInformationClass informationClass) { result = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus SetFileSystemInformation(FileSystemInformation information) { return NTStatus.STATUS_SUCCESS; } + public NTStatus GetSecurityInformation(out SecurityDescriptor result, object handle, SecurityInformation securityInformation) { result = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus SetSecurityInformation(object handle, SecurityInformation securityInformation, SecurityDescriptor securityDescriptor) { return NTStatus.STATUS_SUCCESS; } + public NTStatus NotifyChange(out object ioRequest, object handle, NotifyChangeFilter completionFilter, bool watchTree, int outputBufferSize, OnNotifyChangeCompleted onNotifyChangeCompleted, object context) { ioRequest = null; return NTStatus.STATUS_SUCCESS; } + public NTStatus Cancel(object ioRequest) { return NTStatus.STATUS_SUCCESS; } + public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) + { + output = DeviceIoControlOutput; + return DeviceIoControlStatus; + } + public NTStatus Disconnect() { return NTStatus.STATUS_SUCCESS; } + public uint MaxReadSize { get { return 65536; } } + public uint MaxWriteSize { get { return 65536; } } + } + + private class FakeSmbClient : ISMBClient + { + public bool ConnectResult { get; set; } + public NTStatus LoginResult { get; set; } + + public bool Connect(string serverName, SMBTransportType transport) { return ConnectResult; } + public bool Connect(System.Net.IPAddress serverAddress, SMBTransportType transport) { return ConnectResult; } + public void Disconnect() { } + public NTStatus Login(string domainName, string userName, string password) { return LoginResult; } + public NTStatus Login(string domainName, string userName, string password, AuthenticationMethod authenticationMethod) { return LoginResult; } + public NTStatus Logoff() { return NTStatus.STATUS_SUCCESS; } + public List ListShares(out NTStatus status) { status = NTStatus.STATUS_SUCCESS; return new List(); } + public ISMBFileStore TreeConnect(string shareName, out NTStatus status) + { + status = NTStatus.STATUS_SUCCESS; + return new FakeFileStore(); + } + public NTStatus Echo() { return NTStatus.STATUS_SUCCESS; } + public uint MaxReadSize { get { return 65536; } } + public uint MaxWriteSize { get { return 65536; } } + public bool IsConnected { get { return ConnectResult; } } + } + + #endregion + } +} diff --git a/SMBLibrary.Tests/DFS/DfsLabTestBase.cs b/SMBLibrary.Tests/DFS/DfsLabTestBase.cs new file mode 100644 index 00000000..3252b7a2 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsLabTestBase.cs @@ -0,0 +1,110 @@ +using System; +using System.Net; +using System.Net.Sockets; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// Base class for DFS lab tests requiring live Hyper-V environment. + /// + public abstract class DfsLabTestBase + { + // Lab configuration - matches smb-dfs-lab-status.md + protected static readonly string LabDomain = "LAB.LOCAL"; + protected static readonly string LabDcServer = "10.0.0.10"; + protected static readonly string LabFs1Server = "10.0.0.20"; + protected static readonly string LabFs2Server = "10.0.0.21"; + protected static readonly string DfsNamespacePath = @"\\LAB.LOCAL\Files"; + protected static readonly string DfsFolderPath = @"\\LAB.LOCAL\Files\Sales"; + protected static readonly string DirectShare1 = @"\\LAB-FS1\Sales"; + protected static readonly string DirectShare2 = @"\\LAB-FS2\Sales"; + protected static readonly string SysvolPath = @"\\LAB.LOCAL\SYSVOL"; + protected static readonly string NetlogonPath = @"\\LAB.LOCAL\NETLOGON"; + + protected static string LabUsername { get; private set; } + protected static string LabPassword { get; private set; } + + protected SMB2Client Client { get; private set; } + + // InheritanceBehavior.BeforeEachDerivedClass ensures this ClassInitialize runs + // for each derived test class, allowing proper initialization of lab credentials + // before any test in the derived class executes. + [ClassInitialize(InheritanceBehavior.BeforeEachDerivedClass)] + public static void LabClassInit(TestContext context) + { + LabUsername = Environment.GetEnvironmentVariable("LAB_USERNAME") ?? "Administrator"; + LabPassword = Environment.GetEnvironmentVariable("LAB_PASSWORD"); + } + + [TestInitialize] + public virtual void TestInit() + { + Client = new SMB2Client(); + } + + [TestCleanup] + public virtual void TestCleanup() + { + try + { + Client?.Disconnect(); + } + catch { } + } + + /// + /// Checks if lab environment is available. Call at start of each test. + /// + protected void RequireLabEnvironment() + { + if (string.IsNullOrEmpty(LabPassword)) + { + Assert.Inconclusive("Lab not configured: LAB_PASSWORD environment variable not set"); + } + + // Quick TCP connectivity check to DC + try + { + using (var tcp = new TcpClient()) + { + var result = tcp.BeginConnect(LabDcServer, 445, null, null); + bool success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(3)); + if (!success || !tcp.Connected) + { + Assert.Inconclusive($"Lab DC not reachable at {LabDcServer}:445"); + } + } + } + catch (Exception ex) + { + Assert.Inconclusive($"Lab not available: {ex.Message}"); + } + } + + /// + /// Connects and logs in to the domain controller. + /// + protected void ConnectToDc() + { + bool connected = Client.Connect(IPAddress.Parse(LabDcServer), SMBTransportType.DirectTCPTransport); + Assert.IsTrue(connected, $"Failed to connect to DC at {LabDcServer}"); + + NTStatus status = Client.Login(LabDomain, LabUsername, LabPassword); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status, $"Login failed: {status}"); + } + + /// + /// Connects and logs in to a specific file server by IP. + /// + protected void ConnectToServer(string serverIp) + { + bool connected = Client.Connect(IPAddress.Parse(serverIp), SMBTransportType.DirectTCPTransport); + Assert.IsTrue(connected, $"Failed to connect to {serverIp}"); + + NTStatus status = Client.Login(LabDomain, LabUsername, LabPassword); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status, $"Login failed: {status}"); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DfsLabTests.cs b/SMBLibrary.Tests/DFS/DfsLabTests.cs new file mode 100644 index 00000000..3b95e557 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsLabTests.cs @@ -0,0 +1,1100 @@ +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client; +using SMBLibrary.Client.DFS; +using SMBLibrary.DFS; +using SMBLibrary.SMB2; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// End-to-end DFS tests against live Hyper-V lab environment. + /// Requires: LAB_PASSWORD environment variable set. + /// Run with: dotnet test --filter "TestCategory=Lab" + /// + [TestClass] + [TestCategory("Lab")] + public class DfsLabTests : DfsLabTestBase + { + #region Smoke Tests + + [TestMethod] + [TestCategory("Smoke")] + public void Lab_ConnectToDc_Succeeds() + { + // Arrange + RequireLabEnvironment(); + + // Act + ConnectToDc(); + + // Assert - if we get here, connection succeeded + Assert.IsTrue(Client.IsConnected); + } + + [TestMethod] + [TestCategory("Smoke")] + public void Lab_TreeConnectToIpc_Succeeds() + { + // Arrange + RequireLabEnvironment(); + ConnectToDc(); + + // Act - IPC$ is always available + ISMBFileStore store = Client.TreeConnect("IPC$", out NTStatus status); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status, $"TreeConnect to IPC$ failed: {status}"); + Assert.IsNotNull(store); + + store.Disconnect(); + } + + [TestMethod] + [TestCategory("Smoke")] + public void Lab_DirectShareAccess_Succeeds() + { + // Arrange + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + // Act + ISMBFileStore store = Client.TreeConnect("Sales", out NTStatus status); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status); + + // Verify we can list directory + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, out fileStatus, "", + AccessMask.GENERIC_READ, + FileAttributes.Directory, + ShareAccess.Read | ShareAccess.Write, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus); + store.CloseFile(handle); + store.Disconnect(); + } + + #endregion + + #region DFS Referral Tests + + /// + /// Shorthand for the special FileID used for DFS referral IOCTLs. + /// + private static FileID DfsReferralFileId + { + get { return DfsIoctlRequestBuilder.DfsReferralFileId; } + } + + [TestMethod] + public void Lab_DfsPath_ParsesLabNamespace() + { + // Arrange + RequireLabEnvironment(); + + // Act - Test DFS path parsing for lab namespace + DfsPath path = new DfsPath(DfsFolderPath); + + // Assert + Assert.IsNotNull(path); + Assert.AreEqual("LAB.LOCAL", path.ServerName, true); + Assert.AreEqual("Files", path.ShareName, true); + Console.WriteLine($"Parsed: Server={path.ServerName}, Share={path.ShareName}"); + } + + [TestMethod] + public void Lab_DfsPath_DetectsSysvol() + { + // Arrange + RequireLabEnvironment(); + + // Act + DfsPath sysvolPath = new DfsPath(SysvolPath); + DfsPath netlogonPath = new DfsPath(NetlogonPath); + + // Assert + Assert.IsTrue(sysvolPath.IsSysVolOrNetLogon, "SYSVOL should be detected"); + Assert.IsTrue(netlogonPath.IsSysVolOrNetLogon, "NETLOGON should be detected"); + } + + [TestMethod] + public void Lab_GetSysvolReferral_ReturnsReferral() + { + // Arrange + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(SysvolPath, 16384); + + // Act + byte[] output; + NTStatus ioctlStatus = ipcStore.DeviceIOControl( + DfsReferralFileId, request.CtlCode, request.Input, out output, (int)request.MaxOutputResponse); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ioctlStatus, + $"SYSVOL referral failed: {ioctlStatus}"); + + ResponseGetDfsReferral response = new ResponseGetDfsReferral(output); + Assert.IsTrue(response.ReferralEntries.Count > 0); + + // Log SYSVOL referral details + DfsReferralEntryV3 entry = response.ReferralEntries[0] as DfsReferralEntryV3; + if (entry != null) + { + Console.WriteLine($"SYSVOL Referral:"); + Console.WriteLine($" IsNameListReferral: {entry.IsNameListReferral}"); + Console.WriteLine($" SpecialName: {entry.SpecialName}"); + Console.WriteLine($" NetworkAddress: {entry.NetworkAddress}"); + if (entry.ExpandedNames != null && entry.ExpandedNames.Count > 0) + { + Console.WriteLine($" ExpandedNames ({entry.ExpandedNames.Count}):"); + foreach (var name in entry.ExpandedNames) + { + Console.WriteLine($" - {name}"); + } + } + } + + ipcStore.Disconnect(); + } + + [TestMethod] + public void Lab_GetNetlogonReferral_ReturnsReferral() + { + // Arrange + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(NetlogonPath, 16384); + + // Act + byte[] output; + NTStatus ioctlStatus = ipcStore.DeviceIOControl( + DfsReferralFileId, request.CtlCode, request.Input, out output, (int)request.MaxOutputResponse); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ioctlStatus, + $"NETLOGON referral failed: {ioctlStatus}"); + + ResponseGetDfsReferral response = new ResponseGetDfsReferral(output); + Assert.IsTrue(response.ReferralEntries.Count > 0, "Expected at least one NETLOGON referral"); + + Console.WriteLine($"NETLOGON Referral: {response.ReferralEntries.Count} entries"); + foreach (var e in response.ReferralEntries) + { + if (e is DfsReferralEntryV3 v3) + { + Console.WriteLine($" V{v3.VersionNumber}: NetworkAddress={v3.NetworkAddress}, TTL={v3.TimeToLive}s"); + } + } + + ipcStore.Disconnect(); + } + + [TestMethod] + public void Lab_GetDfsNamespaceReferral_ReturnsBothTargets() + { + // Arrange - Request referral for DFS folder (should return FS1 and FS2) + // NOTE: This test requires DNS resolution of LAB.LOCAL domain. + // When connecting via IP, the server may return STATUS_NOT_FOUND. + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + // Request referral for the DFS folder path + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(DfsFolderPath, 16384); + + // Act + byte[] output; + NTStatus ioctlStatus = ipcStore.DeviceIOControl( + DfsReferralFileId, request.CtlCode, request.Input, out output, (int)request.MaxOutputResponse); + + // Handle case where DNS isn't configured (IP-only connection) + if (ioctlStatus == NTStatus.STATUS_NOT_FOUND) + { + Console.WriteLine($"DFS namespace referral returned STATUS_NOT_FOUND - DNS may not be configured for {DfsFolderPath}"); + Assert.Inconclusive("DFS namespace referral requires DNS resolution of LAB.LOCAL domain"); + } + + // Assert - Should succeed and return 2 targets + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ioctlStatus, + $"DFS namespace referral failed: {ioctlStatus}"); + + ResponseGetDfsReferral response = new ResponseGetDfsReferral(output); + Console.WriteLine($"DFS Namespace Referral for {DfsFolderPath}:"); + Console.WriteLine($" PathConsumed: {response.PathConsumed}"); + Console.WriteLine($" NumberOfReferrals: {response.ReferralEntries.Count}"); + + Assert.AreEqual(2, response.ReferralEntries.Count, + "Lab DFS namespace should have 2 targets (FS1 and FS2)"); + + // Verify both targets are present + List targets = new List(); + foreach (var entry in response.ReferralEntries) + { + if (entry is DfsReferralEntryV3 v3) + { + string addr = v3.NetworkAddress ?? ""; + targets.Add(addr.ToUpperInvariant()); + Console.WriteLine($" Target: {v3.NetworkAddress} (TTL={v3.TimeToLive}s)"); + } + } + + Assert.IsTrue(targets.Exists(t => t.Contains("FS1") || t.Contains("10.0.0.20")), + $"Should have FS1 as target. Got: {string.Join(", ", targets)}"); + Assert.IsTrue(targets.Exists(t => t.Contains("FS2") || t.Contains("10.0.0.21")), + $"Should have FS2 as target. Got: {string.Join(", ", targets)}"); + + ipcStore.Disconnect(); + } + + [TestMethod] + public void Lab_GetDfsReferral_ReturnsV3OrV4Entry() + { + // Arrange + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + // Use SYSVOL which always exists on DC + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(SysvolPath, 16384); + + // Act + byte[] output; + NTStatus ioctlStatus = ipcStore.DeviceIOControl( + DfsReferralFileId, request.CtlCode, request.Input, out output, (int)request.MaxOutputResponse); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ioctlStatus); + + ResponseGetDfsReferral response = new ResponseGetDfsReferral(output); + Assert.IsTrue(response.ReferralEntries.Count > 0); + + // Verify we got V3 or V4 (Windows Server 2008+ always returns V3/V4) + DfsReferralEntry entry = response.ReferralEntries[0]; + Assert.IsTrue(entry is DfsReferralEntryV3 || entry is DfsReferralEntryV4, + $"Expected V3 or V4 entry, got {entry.GetType().Name}"); + + if (entry is DfsReferralEntryV3 v3) + { + Console.WriteLine($"Referral Entry V{v3.VersionNumber}:"); + Console.WriteLine($" ServerType: {v3.ServerType}"); + Console.WriteLine($" ReferralEntryFlags: {v3.ReferralEntryFlags}"); + Console.WriteLine($" TimeToLive: {v3.TimeToLive}s"); + } + + ipcStore.Disconnect(); + } + + [TestMethod] + public void Lab_GetDfsReferral_PathConsumedIsValid() + { + // Arrange + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(SysvolPath, 16384); + + // Act + byte[] output; + NTStatus ioctlStatus = ipcStore.DeviceIOControl( + DfsReferralFileId, request.CtlCode, request.Input, out output, (int)request.MaxOutputResponse); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ioctlStatus); + + ResponseGetDfsReferral response = new ResponseGetDfsReferral(output); + + // PathConsumed should be the number of bytes (UTF-16) consumed from the request path + // For \\LAB.LOCAL\SYSVOL, this should be > 0 and <= path length * 2 + Assert.IsTrue(response.PathConsumed > 0, "PathConsumed should be positive"); + Assert.IsTrue(response.PathConsumed <= SysvolPath.Length * 2, + $"PathConsumed ({response.PathConsumed}) should not exceed path length ({SysvolPath.Length * 2} bytes)"); + + Console.WriteLine($"PathConsumed: {response.PathConsumed} bytes ({response.PathConsumed / 2} chars)"); + + ipcStore.Disconnect(); + } + + [TestMethod] + public void Lab_GetDfsReferralEx_WithSiteName_Succeeds() + { + // Arrange - Test extended referral request with site name + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + // Use extended referral request with site name + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequestEx( + SysvolPath, "Default-First-Site-Name", 16384); + + // Act + byte[] output; + NTStatus ioctlStatus = ipcStore.DeviceIOControl( + DfsReferralFileId, request.CtlCode, request.Input, out output, (int)request.MaxOutputResponse); + + // Assert - Extended referral should work (or return NOT_SUPPORTED/INVALID_PARAMETER on some servers) + if (ioctlStatus == NTStatus.STATUS_NOT_SUPPORTED || ioctlStatus == NTStatus.STATUS_INVALID_PARAMETER) + { + Console.WriteLine($"FSCTL_DFS_GET_REFERRALS_EX returned {ioctlStatus} - server may not support site-aware referrals"); + Assert.Inconclusive($"Server does not support extended referrals: {ioctlStatus}"); + } + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ioctlStatus, + $"Extended referral failed: {ioctlStatus}"); + + ResponseGetDfsReferral response = new ResponseGetDfsReferral(output); + Assert.IsTrue(response.ReferralEntries.Count > 0); + Console.WriteLine($"Extended referral returned {response.ReferralEntries.Count} entries"); + + ipcStore.Disconnect(); + } + + #endregion + + #region File Operations Tests + + [TestMethod] + [TestCategory("Smoke")] + public void Lab_DirectAccess_ConnectToFs2_Succeeds() + { + // Arrange - Verify FS2 is also accessible + RequireLabEnvironment(); + ConnectToServer(LabFs2Server); + + // Act + ISMBFileStore store = Client.TreeConnect("Sales", out NTStatus status); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status, $"TreeConnect to FS2 failed: {status}"); + store.Disconnect(); + } + + [TestMethod] + public void Lab_DirectAccess_CreateFileOnFs1_Succeeds() + { + // Arrange - Connect directly to FS1 via IP (bypasses DFS/DNS) + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore store = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus, $"TreeConnect failed: {treeStatus}"); + + // Act - Create/open a test file directly + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, + out fileStatus, + @"dfs-test-file.txt", + AccessMask.GENERIC_READ | AccessMask.GENERIC_WRITE, + FileAttributes.Normal, + ShareAccess.Read, + CreateDisposition.FILE_OPEN_IF, + CreateOptions.FILE_NON_DIRECTORY_FILE, + null); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus, + $"CreateFile failed: {createStatus}"); + + store.CloseFile(handle); + store.Disconnect(); + } + + [TestMethod] + public void Lab_DirectAccess_QueryDirectoryOnFs1_Succeeds() + { + // Arrange - Connect directly to FS1 via IP + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore store = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + // Open directory handle + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, + out fileStatus, + "", + AccessMask.GENERIC_READ, + FileAttributes.Directory, + ShareAccess.Read | ShareAccess.Write, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus); + + // Act - Query directory contents + List entries; + NTStatus queryStatus = store.QueryDirectory( + out entries, + handle, + "*", + FileInformationClass.FileDirectoryInformation); + + // Assert - STATUS_SUCCESS or STATUS_NO_MORE_FILES are both valid + Assert.IsTrue( + queryStatus == NTStatus.STATUS_SUCCESS || queryStatus == NTStatus.STATUS_NO_MORE_FILES, + $"QueryDirectory failed: {queryStatus}"); + Assert.IsNotNull(entries); + + Console.WriteLine($"Sales share directory ({entries.Count} entries):"); + foreach (var entry in entries) + { + if (entry is FileDirectoryInformation info) + { + Console.WriteLine($" {info.FileName}"); + } + } + + store.CloseFile(handle); + store.Disconnect(); + } + + [TestMethod] + public void Lab_FileReadWrite_RoundTrip_Succeeds() + { + // Arrange + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore store = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + string testFileName = $"dfs-roundtrip-{Guid.NewGuid():N}.txt"; + byte[] testData = System.Text.Encoding.UTF8.GetBytes("Hello from SMBLibrary DFS E2E test!"); + + try + { + // Create and write + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, out fileStatus, testFileName, + AccessMask.GENERIC_READ | AccessMask.GENERIC_WRITE, + FileAttributes.Normal, + ShareAccess.None, + CreateDisposition.FILE_CREATE, + CreateOptions.FILE_NON_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus, $"Create failed: {createStatus}"); + + // Write + int bytesWritten; + NTStatus writeStatus = store.WriteFile(out bytesWritten, handle, 0, testData); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, writeStatus, $"Write failed: {writeStatus}"); + Assert.AreEqual(testData.Length, bytesWritten); + + store.CloseFile(handle); + + // Reopen and read + NTStatus reopenStatus = store.CreateFile( + out handle, out fileStatus, testFileName, + AccessMask.GENERIC_READ, + FileAttributes.Normal, + ShareAccess.Read, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_NON_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, reopenStatus); + + // Read + byte[] readData; + NTStatus readStatus = store.ReadFile(out readData, handle, 0, testData.Length); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, readStatus, $"Read failed: {readStatus}"); + + store.CloseFile(handle); + + // Verify content + CollectionAssert.AreEqual(testData, readData, "Read data should match written data"); + Console.WriteLine($"Successfully wrote and read {testData.Length} bytes"); + } + finally + { + // Cleanup - delete test file + object deleteHandle; + FileStatus deleteStatus; + if (store.CreateFile(out deleteHandle, out deleteStatus, testFileName, + AccessMask.DELETE, FileAttributes.Normal, ShareAccess.None, + CreateDisposition.FILE_OPEN, CreateOptions.FILE_DELETE_ON_CLOSE, null) == NTStatus.STATUS_SUCCESS) + { + store.CloseFile(deleteHandle); + } + store.Disconnect(); + } + } + + #endregion + + #region Caching Tests + + [TestMethod] + public void Lab_ReferralTtl_IsPositive() + { + // Arrange - Use SYSVOL referral which should work on any DC + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(SysvolPath, 16384); + + // Act + byte[] output; + NTStatus ioctlStatus = ipcStore.DeviceIOControl( + DfsReferralFileId, request.CtlCode, request.Input, out output, (int)request.MaxOutputResponse); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ioctlStatus); + + ResponseGetDfsReferral response = new ResponseGetDfsReferral(output); + uint ttl = 0; + if (response.ReferralEntries.Count > 0 && response.ReferralEntries[0] is DfsReferralEntryV3 v3) + { + ttl = v3.TimeToLive; + } + + // Assert - TTL should be reasonable (typically 300-600 seconds) + Assert.IsTrue(ttl > 0, "Referral TTL should be positive"); + Console.WriteLine($"SYSVOL Referral TTL: {ttl} seconds"); + + ipcStore.Disconnect(); + } + + #endregion + + #region DFS Resolution Tests + + [TestMethod] + public void Lab_DfsPathResolver_ResolvesPath() + { + // Arrange - Test DfsPathResolver directly + RequireLabEnvironment(); + ConnectToDc(); + + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus treeStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + // Create a transport that sends referral requests + var transport = Smb2DfsReferralTransport.CreateUsingDeviceIOControl(ipcStore, DfsReferralFileId); + var referralCache = new ReferralCache(); + var domainCache = new DomainCache(); + var resolver = new DfsPathResolver(referralCache, domainCache, transport); + + DfsClientOptions options = new DfsClientOptions { Enabled = true }; + + // Act - Resolve SYSVOL path + DfsResolutionResult result = resolver.Resolve(options, SysvolPath); + + // Assert - Log the result for diagnostics + Console.WriteLine($"Resolution result: Status={result.Status}, ResolvedPath={result.ResolvedPath}"); + + // The resolver may return different statuses depending on implementation + // Success = fully resolved, NotApplicable = not a DFS path, Error = resolution failed but original path returned + Assert.IsNotNull(result.ResolvedPath, "ResolvedPath should not be null"); + + // If error, it should still return the original path for fallback + if (result.Status == DfsResolutionStatus.Error) + { + Console.WriteLine("Resolver returned Error - this is acceptable for paths that can't be fully resolved"); + Assert.AreEqual(SysvolPath, result.ResolvedPath, "On error, should return original path"); + } + else if (result.Status == DfsResolutionStatus.Success) + { + Console.WriteLine($"SYSVOL resolved to: {result.ResolvedPath}"); + } + + ipcStore.Disconnect(); + } + + [TestMethod] + public void Lab_DfsClientFactory_WrapperPassthroughWhenDisabled() + { + // Arrange - DFS disabled should return inner store unchanged + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore innerStore = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + DfsClientOptions options = new DfsClientOptions { Enabled = false }; + + // Act + ISMBFileStore wrappedStore = DfsClientFactory.CreateDfsAwareFileStore(innerStore, null, options); + + // Assert - When disabled, should return the same store + Assert.AreSame(innerStore, wrappedStore, "With DFS disabled, factory should return inner store"); + + wrappedStore.Disconnect(); + } + + [TestMethod] + public void Lab_ReferralCache_StoresAndRetrievesEntry() + { + // Arrange - Test cache behavior + RequireLabEnvironment(); + + var cache = new ReferralCache(); + + // Create a cache entry manually with proper expiration + var entry = new ReferralCacheEntry(SysvolPath); + entry.TtlSeconds = 300; + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(300); // Must set ExpiresUtc or entry is considered expired + entry.TargetList.Add(new TargetSetEntry(@"\\LAB-DC1\SYSVOL")); + + // Act - Store and retrieve + cache.Add(entry); + var retrieved = cache.Lookup(SysvolPath); + + // Assert + Assert.IsNotNull(retrieved, "Cache should return the stored entry"); + Assert.AreEqual(SysvolPath.ToUpperInvariant(), retrieved.DfsPathPrefix.ToUpperInvariant()); + Assert.IsFalse(retrieved.IsExpired, "Entry should not be expired immediately"); + Assert.AreEqual(1, retrieved.TargetList.Count); + Console.WriteLine($"Cached entry: Prefix={retrieved.DfsPathPrefix}, TTL={retrieved.TtlSeconds}s, Targets={retrieved.TargetList.Count}"); + } + + [TestMethod] + public void Lab_TargetHint_RotatesTargets() + { + // Arrange - Test target rotation + RequireLabEnvironment(); + + var entry = new ReferralCacheEntry(SysvolPath); + entry.TtlSeconds = 300; + entry.TargetList.Add(new TargetSetEntry(@"\\DC1\SYSVOL")); + entry.TargetList.Add(new TargetSetEntry(@"\\DC2\SYSVOL")); + + // Act - Get target hints + var firstHint = entry.GetTargetHint(); + Console.WriteLine($"First target hint: {firstHint?.TargetPath}"); + + entry.NextTargetHint(); + var secondHint = entry.GetTargetHint(); + Console.WriteLine($"Second target hint: {secondHint?.TargetPath}"); + + // Assert - Should rotate + Assert.AreNotEqual(firstHint?.TargetPath, secondHint?.TargetPath, + "NextTargetHint should rotate to different target"); + + // Reset and verify + entry.ResetTargetHint(); + var resetHint = entry.GetTargetHint(); + Assert.AreEqual(firstHint?.TargetPath, resetHint?.TargetPath, + "ResetTargetHint should return to first target"); + } + + #endregion + + #region DfsSessionManager Tests + + [TestMethod] + public void Lab_DfsSessionManager_CreatesSession() + { + // Arrange - Test DfsSessionManager can create sessions + RequireLabEnvironment(); + + using (var sessionManager = new DfsSessionManager()) + { + // Act - Get session to FS1 + var credentials = new DfsCredentials(LabDomain, LabUsername, LabPassword); + NTStatus status; + ISMBFileStore store = sessionManager.GetOrCreateSession( + LabFs1Server, "Sales", credentials, out status); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status, $"GetOrCreateSession failed: {status}"); + Assert.IsNotNull(store); + + Console.WriteLine($"Successfully created session to {LabFs1Server}\\Sales"); + + // Verify we can use the store + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, out fileStatus, "", + AccessMask.GENERIC_READ, + FileAttributes.Directory, + ShareAccess.Read, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus); + store.CloseFile(handle); + } + } + + [TestMethod] + public void Lab_DfsSessionManager_ReusesSameServerSession() + { + // Arrange + RequireLabEnvironment(); + + using (var sessionManager = new DfsSessionManager()) + { + var credentials = new DfsCredentials(LabDomain, LabUsername, LabPassword); + + // Act - Get two sessions to same server, different conceptual paths + NTStatus status1, status2; + ISMBFileStore store1 = sessionManager.GetOrCreateSession( + LabFs1Server, "Sales", credentials, out status1); + ISMBFileStore store2 = sessionManager.GetOrCreateSession( + LabFs1Server, "Sales", credentials, out status2); + + // Assert - Both should succeed + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status1); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status2); + + // Should reuse the same underlying client/connection + Console.WriteLine("Successfully reused session for same server"); + } + } + + [TestMethod] + public void Lab_DfsSessionManager_CreatesSeparateSessionsForDifferentServers() + { + // Arrange + RequireLabEnvironment(); + + using (var sessionManager = new DfsSessionManager()) + { + var credentials = new DfsCredentials(LabDomain, LabUsername, LabPassword); + + // Act - Get sessions to different servers + NTStatus status1, status2; + ISMBFileStore storeFs1 = sessionManager.GetOrCreateSession( + LabFs1Server, "Sales", credentials, out status1); + ISMBFileStore storeFs2 = sessionManager.GetOrCreateSession( + LabFs2Server, "Sales", credentials, out status2); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status1, $"FS1 session failed: {status1}"); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status2, $"FS2 session failed: {status2}"); + + // Stores should be different instances + Assert.AreNotSame(storeFs1, storeFs2, "Different servers should have different stores"); + + Console.WriteLine($"Successfully created separate sessions for {LabFs1Server} and {LabFs2Server}"); + } + } + + #endregion + + #region DFS-Aware Adapter End-to-End Tests + + [TestMethod] + public void Lab_DfsAwareAdapter_DirectAccessWithDfsDisabled_Succeeds() + { + // Arrange - DFS disabled should work for direct share access + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore innerStore = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + // Create DFS-aware adapter with DFS disabled + DfsClientOptions options = new DfsClientOptions { Enabled = false }; + ISMBFileStore store = DfsClientFactory.CreateDfsAwareFileStore(innerStore, null, options); + + // Act - Access directory through adapter + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, out fileStatus, "", + AccessMask.GENERIC_READ, + FileAttributes.Directory, + ShareAccess.Read, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_DIRECTORY_FILE, + null); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus, + $"Should access share directly when DFS disabled: {createStatus}"); + + store.CloseFile(handle); + store.Disconnect(); + } + + [TestMethod] + public void Lab_DfsAwareAdapter_WithDfsEnabled_DirectShareAccess_Succeeds() + { + // Arrange - DFS enabled but accessing non-DFS path should still work + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore innerStore = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + // Create transport for referrals + // Note: In lab tests, innerStore is always SMB2FileStore; transport will be non-null + SMB2FileStore smb2Store = innerStore as SMB2FileStore; + IDfsReferralTransport transport = null; + if (smb2Store != null) + { + transport = Smb2DfsReferralTransport.CreateUsingDeviceIOControl(smb2Store, DfsReferralFileId); + } + + // Create DFS-aware adapter with resolver (transport may be null if not SMB2, resolver handles gracefully) + var resolver = new DfsClientResolver(transport); + DfsClientOptions options = new DfsClientOptions { Enabled = true }; + + ISMBFileStore store = DfsClientFactory.CreateDfsAwareFileStore(innerStore, resolver, options); + + // Act - Access directory (non-DFS path should pass through) + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, out fileStatus, "", + AccessMask.GENERIC_READ, + FileAttributes.Directory, + ShareAccess.Read, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_DIRECTORY_FILE, + null); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus, + $"Direct share access with DFS enabled should work: {createStatus}"); + + store.CloseFile(handle); + store.Disconnect(); + } + + [TestMethod] + public void Lab_DfsAwareAdapter_FileReadWrite_Succeeds() + { + // Arrange - Test file operations through DFS-aware adapter + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore innerStore = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + DfsClientOptions options = new DfsClientOptions { Enabled = false }; + ISMBFileStore store = DfsClientFactory.CreateDfsAwareFileStore(innerStore, null, options); + + string testFileName = $"dfs-adapter-test-{Guid.NewGuid():N}.txt"; + byte[] testData = System.Text.Encoding.UTF8.GetBytes("DFS Adapter E2E Test Data"); + + try + { + // Act - Create and write file + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, out fileStatus, testFileName, + AccessMask.GENERIC_READ | AccessMask.GENERIC_WRITE, + FileAttributes.Normal, + ShareAccess.None, + CreateDisposition.FILE_CREATE, + CreateOptions.FILE_NON_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus); + + int bytesWritten; + NTStatus writeStatus = store.WriteFile(out bytesWritten, handle, 0, testData); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, writeStatus); + + store.CloseFile(handle); + + // Read back + NTStatus reopenStatus = store.CreateFile( + out handle, out fileStatus, testFileName, + AccessMask.GENERIC_READ, + FileAttributes.Normal, + ShareAccess.Read, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_NON_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, reopenStatus); + + byte[] readData; + NTStatus readStatus = store.ReadFile(out readData, handle, 0, testData.Length); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, readStatus); + + store.CloseFile(handle); + + // Assert + CollectionAssert.AreEqual(testData, readData, "Data should round-trip through DFS adapter"); + Console.WriteLine($"Successfully read/wrote {testData.Length} bytes through DFS adapter"); + } + finally + { + // Cleanup + object deleteHandle; + FileStatus deleteStatus; + if (store.CreateFile(out deleteHandle, out deleteStatus, testFileName, + AccessMask.DELETE, FileAttributes.Normal, ShareAccess.None, + CreateDisposition.FILE_OPEN, CreateOptions.FILE_DELETE_ON_CLOSE, null) == NTStatus.STATUS_SUCCESS) + { + store.CloseFile(deleteHandle); + } + store.Disconnect(); + } + } + + [TestMethod] + public void Lab_DfsAwareAdapter_QueryDirectory_Succeeds() + { + // Arrange - Test QueryDirectory through adapter + RequireLabEnvironment(); + ConnectToServer(LabFs1Server); + + ISMBFileStore innerStore = Client.TreeConnect("Sales", out NTStatus treeStatus); + Assert.AreEqual(NTStatus.STATUS_SUCCESS, treeStatus); + + DfsClientOptions options = new DfsClientOptions { Enabled = false }; + ISMBFileStore store = DfsClientFactory.CreateDfsAwareFileStore(innerStore, null, options); + + // Act - Open directory and query contents + object handle; + FileStatus fileStatus; + NTStatus createStatus = store.CreateFile( + out handle, out fileStatus, "", + AccessMask.GENERIC_READ, + FileAttributes.Directory, + ShareAccess.Read | ShareAccess.Write, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_DIRECTORY_FILE, + null); + + Assert.AreEqual(NTStatus.STATUS_SUCCESS, createStatus); + + List entries; + NTStatus queryStatus = store.QueryDirectory( + out entries, handle, "*", FileInformationClass.FileDirectoryInformation); + + // Assert + Assert.IsTrue( + queryStatus == NTStatus.STATUS_SUCCESS || queryStatus == NTStatus.STATUS_NO_MORE_FILES, + $"QueryDirectory should succeed: {queryStatus}"); + Assert.IsNotNull(entries); + + Console.WriteLine($"QueryDirectory returned {entries.Count} entries through DFS adapter"); + + store.CloseFile(handle); + store.Disconnect(); + } + + [TestMethod] + public void Lab_FullDfsResolution_SysvolPath() + { + // Arrange - Test full DFS resolution path for SYSVOL + RequireLabEnvironment(); + ConnectToDc(); + + // Connect to IPC$ for DFS referrals + SMB2FileStore ipcStore = Client.TreeConnect("IPC$", out NTStatus ipcStatus) as SMB2FileStore; + Assert.AreEqual(NTStatus.STATUS_SUCCESS, ipcStatus); + + // Create full DFS resolver stack + var referralCache = new ReferralCache(); + var domainCache = new DomainCache(); + var transport = Smb2DfsReferralTransport.CreateUsingDeviceIOControl(ipcStore, DfsReferralFileId); + var resolver = new DfsPathResolver(referralCache, domainCache, transport); + + DfsClientOptions options = new DfsClientOptions { Enabled = true }; + + // Act - Resolve SYSVOL path (always exists on DC) + DfsResolutionResult result = resolver.Resolve(options, SysvolPath); + + // Assert + Console.WriteLine($"SYSVOL Resolution:"); + Console.WriteLine($" Original: {result.OriginalPath}"); + Console.WriteLine($" Resolved: {result.ResolvedPath}"); + Console.WriteLine($" Status: {result.Status}"); + Console.WriteLine($" IsDfsPath: {result.IsDfsPath}"); + + // Resolution should complete (may be success or error, but should return something) + Assert.IsNotNull(result); + Assert.IsNotNull(result.ResolvedPath, "ResolvedPath should not be null"); + + // Check cache was populated + ReferralCacheEntry cached = referralCache.Lookup(SysvolPath); + if (cached != null) + { + Console.WriteLine($" Cache populated: TTL={cached.TtlSeconds}s, Targets={cached.TargetList.Count}"); + } + + ipcStore.Disconnect(); + } + + #endregion + + #region Failover Tests + + [TestMethod] + [TestCategory("Failover")] + public void Lab_Failover_WhenPrimaryDown_SecondTargetAccessible() + { + // NOTE: This test requires LAB-FS1 to be stopped before running. + // Use: .\Run-DfsLabTests.ps1 -IncludeFailover + + // Arrange + RequireLabEnvironment(); + + // Verify FS1 is actually down (test precondition) + bool fs1Reachable = false; + try + { + using (var tcp = new System.Net.Sockets.TcpClient()) + { + var result = tcp.BeginConnect(LabFs1Server, 445, null, null); + fs1Reachable = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(2)) && tcp.Connected; + } + } + catch { } + + if (fs1Reachable) + { + Assert.Inconclusive("Failover test requires LAB-FS1 to be stopped. Use -IncludeFailover flag."); + } + + // Act - Connect directly to FS2 (simulating failover) + ConnectToServer(LabFs2Server); + ISMBFileStore store = Client.TreeConnect("Sales", out NTStatus status); + + // Assert - FS2 should be accessible + Assert.AreEqual(NTStatus.STATUS_SUCCESS, status, + "Should be able to access Sales share on FS2 when FS1 is down"); + + store.Disconnect(); + } + + #endregion + } +} diff --git a/SMBLibrary.Tests/DFS/DfsPathResolverTests.cs b/SMBLibrary.Tests/DFS/DfsPathResolverTests.cs new file mode 100644 index 00000000..7efd9eef --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsPathResolverTests.cs @@ -0,0 +1,275 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// Unit tests for DfsPathResolver implementing the 14-step MS-DFSC resolution algorithm. + /// + [TestClass] + public class DfsPathResolverTests + { + #region Step 1: Single Component / IPC$ Check + + [TestMethod] + public void Resolve_SingleComponentPath_ReturnsNotDfsAndOriginalPath() + { + // Per MS-DFSC Step 1: If path has only one component, it's not DFS + // Arrange + var resolver = new DfsPathResolver(); + var options = new DfsClientOptions { Enabled = true }; + + // Act + var result = resolver.Resolve(options, @"\\server"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(@"\\server", result.ResolvedPath); + Assert.IsFalse(result.IsDfsPath); + } + + [TestMethod] + public void Resolve_IpcPath_ReturnsNotDfsAndOriginalPath() + { + // Per MS-DFSC Step 1: IPC$ paths are not DFS + // Arrange + var resolver = new DfsPathResolver(); + var options = new DfsClientOptions { Enabled = true }; + + // Act + var result = resolver.Resolve(options, @"\\server\IPC$"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(@"\\server\IPC$", result.ResolvedPath); + Assert.IsFalse(result.IsDfsPath); + } + + [TestMethod] + public void Resolve_IpcPathCaseInsensitive_ReturnsNotDfs() + { + // Arrange + var resolver = new DfsPathResolver(); + var options = new DfsClientOptions { Enabled = true }; + + // Act + var result = resolver.Resolve(options, @"\\server\ipc$\pipe"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.IsFalse(result.IsDfsPath); + } + + #endregion + + #region DFS Disabled + + [TestMethod] + public void Resolve_WhenDfsDisabled_ReturnsNotApplicable() + { + // Arrange + var resolver = new DfsPathResolver(); + var options = new DfsClientOptions { Enabled = false }; + + // Act + var result = resolver.Resolve(options, @"\\domain\share\folder"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(@"\\domain\share\folder", result.ResolvedPath); + } + + [TestMethod] + public void Resolve_WhenOptionsNull_ThrowsArgumentNullException() + { + // Arrange + var resolver = new DfsPathResolver(); + + // Act & Assert + Assert.ThrowsException(() => + resolver.Resolve(null, @"\\server\share")); + } + + #endregion + + #region Step 2: Cache Lookup + + [TestMethod] + public void Resolve_CachedRootEntry_ReturnsResolvedPathFromCache() + { + // Per MS-DFSC Step 2: Check ReferralCache for matching entry + // Arrange + var cache = new ReferralCache(); + var entry = new ReferralCacheEntry(@"\\domain\dfsroot") + { + RootOrLink = ReferralCacheEntryType.Root, + ExpiresUtc = DateTime.UtcNow.AddHours(1) // Not expired + }; + entry.TargetList.Add(new TargetSetEntry(@"\\server1\share")); + cache.Add(entry); + + var resolver = new DfsPathResolver(cache, null, null); + var options = new DfsClientOptions { Enabled = true }; + + // Act + var result = resolver.Resolve(options, @"\\domain\dfsroot\folder\file.txt"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Success, result.Status); + Assert.AreEqual(@"\\server1\share\folder\file.txt", result.ResolvedPath); + Assert.IsTrue(result.IsDfsPath); + } + + [TestMethod] + public void Resolve_CachedLinkEntry_ReturnsResolvedPathFromCache() + { + // Per MS-DFSC Step 4: LINK cache hit + // Arrange + var cache = new ReferralCache(); + var entry = new ReferralCacheEntry(@"\\domain\dfsroot\link1") + { + RootOrLink = ReferralCacheEntryType.Link, + ExpiresUtc = DateTime.UtcNow.AddHours(1) // Not expired + }; + entry.TargetList.Add(new TargetSetEntry(@"\\server2\targetshare")); + cache.Add(entry); + + var resolver = new DfsPathResolver(cache, null, null); + var options = new DfsClientOptions { Enabled = true }; + + // Act + var result = resolver.Resolve(options, @"\\domain\dfsroot\link1\subfolder"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.Success, result.Status); + Assert.AreEqual(@"\\server2\targetshare\subfolder", result.ResolvedPath); + Assert.IsTrue(result.IsDfsPath); + } + + [TestMethod] + public void Resolve_NoMatchingCacheEntry_ProceedsToReferralRequest() + { + // Arrange + var cache = new ReferralCache(); // empty cache + var fakeTransport = new FakeDfsReferralTransport(); + fakeTransport.SetResponse(NTStatus.STATUS_FS_DRIVER_REQUIRED, null, 0); // server not DFS capable + + var resolver = new DfsPathResolver(cache, null, fakeTransport); + var options = new DfsClientOptions { Enabled = true }; + + // Act + var result = resolver.Resolve(options, @"\\server\share\folder"); + + // Assert - should have attempted transport call + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.IsTrue(fakeTransport.WasCalled); + } + + #endregion + + #region Step 12: Not DFS Path + + [TestMethod] + public void Resolve_ServerNotDfsCapable_ReturnsNotApplicable() + { + // Per MS-DFSC Step 12: Server returns STATUS_FS_DRIVER_REQUIRED + // Arrange + var fakeTransport = new FakeDfsReferralTransport(); + fakeTransport.SetResponse(NTStatus.STATUS_FS_DRIVER_REQUIRED, null, 0); + + var resolver = new DfsPathResolver(null, null, fakeTransport); + var options = new DfsClientOptions { Enabled = true }; + + // Act + var result = resolver.Resolve(options, @"\\server\share\folder"); + + // Assert + Assert.AreEqual(DfsResolutionStatus.NotApplicable, result.Status); + Assert.AreEqual(@"\\server\share\folder", result.ResolvedPath); + Assert.IsFalse(result.IsDfsPath); + } + + #endregion + + #region Event Raising + + [TestMethod] + public void Resolve_RaisesResolutionStartedEvent() + { + // Arrange + var resolver = new DfsPathResolver(); + var options = new DfsClientOptions { Enabled = true }; + bool eventRaised = false; + string eventPath = null; + + resolver.ResolutionStarted += (sender, e) => + { + eventRaised = true; + eventPath = e.Path; + }; + + // Act + resolver.Resolve(options, @"\\server\share"); + + // Assert + Assert.IsTrue(eventRaised); + Assert.AreEqual(@"\\server\share", eventPath); + } + + [TestMethod] + public void Resolve_RaisesResolutionCompletedEvent() + { + // Arrange + var resolver = new DfsPathResolver(); + var options = new DfsClientOptions { Enabled = true }; + bool eventRaised = false; + DfsResolutionCompletedEventArgs capturedArgs = null; + + resolver.ResolutionCompleted += (sender, e) => + { + eventRaised = true; + capturedArgs = e; + }; + + // Act + resolver.Resolve(options, @"\\server"); + + // Assert + Assert.IsTrue(eventRaised); + Assert.AreEqual(@"\\server", capturedArgs.OriginalPath); + } + + #endregion + + #region Helper: Fake Transport + + private class FakeDfsReferralTransport : IDfsReferralTransport + { + private NTStatus _status; + private byte[] _buffer; + private uint _outputCount; + + public bool WasCalled { get; private set; } + public string LastPath { get; private set; } + + public void SetResponse(NTStatus status, byte[] buffer, uint outputCount) + { + _status = status; + _buffer = buffer; + _outputCount = outputCount; + } + + public NTStatus TryGetReferrals(string serverName, string dfsPath, uint maxOutputSize, out byte[] buffer, out uint outputCount) + { + WasCalled = true; + LastPath = dfsPath; + buffer = _buffer; + outputCount = _outputCount; + return _status; + } + } + + #endregion + } +} diff --git a/SMBLibrary.Tests/DFS/DfsPathTests.cs b/SMBLibrary.Tests/DFS/DfsPathTests.cs new file mode 100644 index 00000000..00a02f0c --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsPathTests.cs @@ -0,0 +1,356 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client.DFS; +using System; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class DfsPathTests + { + #region Constructor and Parsing + + [TestMethod] + public void Constructor_ValidUncPath_ParsesComponents() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\share\folder\file.txt"); + + // Assert + Assert.AreEqual(4, dfsPath.PathComponents.Count); + Assert.AreEqual("server", dfsPath.PathComponents[0]); + Assert.AreEqual("share", dfsPath.PathComponents[1]); + Assert.AreEqual("folder", dfsPath.PathComponents[2]); + Assert.AreEqual("file.txt", dfsPath.PathComponents[3]); + } + + [TestMethod] + public void Constructor_LeadingSlashes_HandledCorrectly() + { + // Arrange & Act - forward slashes should also work + var dfsPath = new DfsPath(@"//server/share/folder"); + + // Assert + Assert.AreEqual(3, dfsPath.PathComponents.Count); + Assert.AreEqual("server", dfsPath.PathComponents[0]); + Assert.AreEqual("share", dfsPath.PathComponents[1]); + Assert.AreEqual("folder", dfsPath.PathComponents[2]); + } + + [TestMethod] + public void Constructor_MixedSlashes_NormalizesCorrectly() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server/share\folder"); + + // Assert + Assert.AreEqual(3, dfsPath.PathComponents.Count); + Assert.AreEqual("server", dfsPath.PathComponents[0]); + Assert.AreEqual("share", dfsPath.PathComponents[1]); + Assert.AreEqual("folder", dfsPath.PathComponents[2]); + } + + [TestMethod] + public void Constructor_TrailingSlash_IgnoresEmptyComponent() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\share\"); + + // Assert + Assert.AreEqual(2, dfsPath.PathComponents.Count); + Assert.AreEqual("server", dfsPath.PathComponents[0]); + Assert.AreEqual("share", dfsPath.PathComponents[1]); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Constructor_NullPath_ThrowsArgumentNullException() + { + // Act + var dfsPath = new DfsPath(null); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void Constructor_EmptyPath_ThrowsArgumentException() + { + // Act + var dfsPath = new DfsPath(string.Empty); + } + + [TestMethod] + public void Constructor_OnlySlashes_ReturnsEmptyComponents() + { + // Arrange & Act - degenerate path with only slashes + var dfsPath = new DfsPath(@"\\\\"); + + // Assert - should result in empty components list + Assert.AreEqual(0, dfsPath.PathComponents.Count); + } + + [TestMethod] + public void Constructor_ForwardSlashesOnly_ReturnsEmptyComponents() + { + // Arrange & Act + var dfsPath = new DfsPath(@"//"); + + // Assert + Assert.AreEqual(0, dfsPath.PathComponents.Count); + } + + #endregion + + #region HasOnlyOneComponent + + [TestMethod] + public void HasOnlyOneComponent_SingleComponent_ReturnsTrue() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server"); + + // Assert + Assert.IsTrue(dfsPath.HasOnlyOneComponent); + } + + [TestMethod] + public void HasOnlyOneComponent_MultipleComponents_ReturnsFalse() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\share"); + + // Assert + Assert.IsFalse(dfsPath.HasOnlyOneComponent); + } + + #endregion + + #region IsSysVolOrNetLogon + + [TestMethod] + public void IsSysVolOrNetLogon_SysvolPath_ReturnsTrue() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\domain.com\SYSVOL\folder"); + + // Assert + Assert.IsTrue(dfsPath.IsSysVolOrNetLogon); + } + + [TestMethod] + public void IsSysVolOrNetLogon_SysvolPathLowerCase_ReturnsTrue() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\domain.com\sysvol\folder"); + + // Assert + Assert.IsTrue(dfsPath.IsSysVolOrNetLogon); + } + + [TestMethod] + public void IsSysVolOrNetLogon_NetlogonPath_ReturnsTrue() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\domain.com\NETLOGON\scripts"); + + // Assert + Assert.IsTrue(dfsPath.IsSysVolOrNetLogon); + } + + [TestMethod] + public void IsSysVolOrNetLogon_NetlogonPathLowerCase_ReturnsTrue() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\domain.com\netlogon\scripts"); + + // Assert + Assert.IsTrue(dfsPath.IsSysVolOrNetLogon); + } + + [TestMethod] + public void IsSysVolOrNetLogon_RegularShare_ReturnsFalse() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\share\folder"); + + // Assert + Assert.IsFalse(dfsPath.IsSysVolOrNetLogon); + } + + [TestMethod] + public void IsSysVolOrNetLogon_SingleComponent_ReturnsFalse() + { + // Arrange & Act - No share component to check + var dfsPath = new DfsPath(@"\\server"); + + // Assert + Assert.IsFalse(dfsPath.IsSysVolOrNetLogon); + } + + #endregion + + #region IsIpc + + [TestMethod] + public void IsIpc_IpcPath_ReturnsTrue() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\IPC$"); + + // Assert + Assert.IsTrue(dfsPath.IsIpc); + } + + [TestMethod] + public void IsIpc_IpcPathLowerCase_ReturnsTrue() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\ipc$"); + + // Assert + Assert.IsTrue(dfsPath.IsIpc); + } + + [TestMethod] + public void IsIpc_RegularShare_ReturnsFalse() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\share"); + + // Assert + Assert.IsFalse(dfsPath.IsIpc); + } + + [TestMethod] + public void IsIpc_SingleComponent_ReturnsFalse() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server"); + + // Assert + Assert.IsFalse(dfsPath.IsIpc); + } + + #endregion + + #region ToUncPath + + [TestMethod] + public void ToUncPath_ReturnsValidUncString() + { + // Arrange + var dfsPath = new DfsPath(@"\\server\share\folder\file.txt"); + + // Act + string result = dfsPath.ToUncPath(); + + // Assert + Assert.AreEqual(@"\\server\share\folder\file.txt", result); + } + + [TestMethod] + public void ToUncPath_SingleComponent_ReturnsValidUncString() + { + // Arrange + var dfsPath = new DfsPath(@"\\server"); + + // Act + string result = dfsPath.ToUncPath(); + + // Assert + Assert.AreEqual(@"\\server", result); + } + + #endregion + + #region ReplacePrefix + + [TestMethod] + public void ReplacePrefix_ValidPrefix_ReplacesCorrectly() + { + // Arrange + var originalPath = new DfsPath(@"\\contoso.com\dfs\share\folder\file.txt"); + var newPrefix = new DfsPath(@"\\fileserver1\share"); + + // Act - Replace "\\contoso.com\dfs\share" with "\\fileserver1\share" + var result = originalPath.ReplacePrefix(@"\contoso.com\dfs\share", newPrefix); + + // Assert + Assert.AreEqual(@"\\fileserver1\share\folder\file.txt", result.ToUncPath()); + } + + [TestMethod] + public void ReplacePrefix_PrefixMatchesEntirePath_ReturnsNewPrefix() + { + // Arrange + var originalPath = new DfsPath(@"\\contoso.com\dfs\share"); + var newPrefix = new DfsPath(@"\\fileserver1\share"); + + // Act + var result = originalPath.ReplacePrefix(@"\contoso.com\dfs\share", newPrefix); + + // Assert + Assert.AreEqual(@"\\fileserver1\share", result.ToUncPath()); + } + + [TestMethod] + public void ReplacePrefix_CaseInsensitive_ReplacesCorrectly() + { + // Arrange + var originalPath = new DfsPath(@"\\CONTOSO.COM\DFS\Share\folder"); + var newPrefix = new DfsPath(@"\\fileserver1\share"); + + // Act + var result = originalPath.ReplacePrefix(@"\contoso.com\dfs\share", newPrefix); + + // Assert + Assert.AreEqual(@"\\fileserver1\share\folder", result.ToUncPath()); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void ReplacePrefix_PrefixNotMatching_ThrowsArgumentException() + { + // Arrange + var originalPath = new DfsPath(@"\\server\share\folder"); + var newPrefix = new DfsPath(@"\\other\path"); + + // Act + originalPath.ReplacePrefix(@"\different\prefix", newPrefix); + } + + #endregion + + #region ServerName and ShareName + + [TestMethod] + public void ServerName_ReturnsFirstComponent() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\share\folder"); + + // Assert + Assert.AreEqual("server", dfsPath.ServerName); + } + + [TestMethod] + public void ShareName_ReturnsSecondComponent() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server\share\folder"); + + // Assert + Assert.AreEqual("share", dfsPath.ShareName); + } + + [TestMethod] + public void ShareName_SingleComponent_ReturnsNull() + { + // Arrange & Act + var dfsPath = new DfsPath(@"\\server"); + + // Assert + Assert.IsNull(dfsPath.ShareName); + } + + #endregion + } +} diff --git a/SMBLibrary.Tests/DFS/DfsReferralEntryFlagsTests.cs b/SMBLibrary.Tests/DFS/DfsReferralEntryFlagsTests.cs new file mode 100644 index 00000000..7760060e --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsReferralEntryFlagsTests.cs @@ -0,0 +1,38 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.DFS; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class DfsReferralEntryFlagsTests + { + [TestMethod] + public void NameListReferral_HasCorrectValue() + { + // MS-DFSC 2.2.4.x: NameListReferral = 0x0002 + Assert.AreEqual((ushort)0x0002, (ushort)DfsReferralEntryFlags.NameListReferral); + } + + [TestMethod] + public void TargetSetBoundary_HasCorrectValue() + { + // MS-DFSC 2.2.4.4: TargetSetBoundary = 0x0004 + Assert.AreEqual((ushort)0x0004, (ushort)DfsReferralEntryFlags.TargetSetBoundary); + } + + [TestMethod] + public void CombinedFlags_CanBeParsed() + { + // Arrange + ushort rawValue = 0x0006; // NameListReferral + TargetSetBoundary + + // Act + DfsReferralEntryFlags flags = (DfsReferralEntryFlags)rawValue; + + // Assert + Assert.IsTrue((flags & DfsReferralEntryFlags.NameListReferral) == DfsReferralEntryFlags.NameListReferral); + Assert.IsTrue((flags & DfsReferralEntryFlags.TargetSetBoundary) == DfsReferralEntryFlags.TargetSetBoundary); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DfsReferralHeaderFlagsTests.cs b/SMBLibrary.Tests/DFS/DfsReferralHeaderFlagsTests.cs new file mode 100644 index 00000000..377d0d52 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsReferralHeaderFlagsTests.cs @@ -0,0 +1,46 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.DFS; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class DfsReferralHeaderFlagsTests + { + [TestMethod] + public void ReferralServers_HasCorrectValue() + { + // MS-DFSC 2.2.4: R bit = 0x00000001 + Assert.AreEqual((uint)0x00000001, (uint)DfsReferralHeaderFlags.ReferralServers); + } + + [TestMethod] + public void StorageServers_HasCorrectValue() + { + // MS-DFSC 2.2.4: S bit = 0x00000002 + Assert.AreEqual((uint)0x00000002, (uint)DfsReferralHeaderFlags.StorageServers); + } + + [TestMethod] + public void TargetFailback_HasCorrectValue() + { + // MS-DFSC 2.2.4: T bit = 0x00000004 + Assert.AreEqual((uint)0x00000004, (uint)DfsReferralHeaderFlags.TargetFailback); + } + + [TestMethod] + public void CombinedFlags_CanBeParsed() + { + // Arrange + uint rawValue = 0x00000007; // R + S + T + + // Act + DfsReferralHeaderFlags flags = (DfsReferralHeaderFlags)rawValue; + + // Assert + Assert.IsTrue((flags & DfsReferralHeaderFlags.ReferralServers) == DfsReferralHeaderFlags.ReferralServers); + Assert.IsTrue((flags & DfsReferralHeaderFlags.StorageServers) == DfsReferralHeaderFlags.StorageServers); + Assert.IsTrue((flags & DfsReferralHeaderFlags.TargetFailback) == DfsReferralHeaderFlags.TargetFailback); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DfsRequestTypeTests.cs b/SMBLibrary.Tests/DFS/DfsRequestTypeTests.cs new file mode 100644 index 00000000..fcfdacd2 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsRequestTypeTests.cs @@ -0,0 +1,58 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// Unit tests for DfsRequestType enum per MS-DFSC referral request types. + /// + [TestClass] + public class DfsRequestTypeTests + { + [TestMethod] + public void DomainReferral_HasCorrectValue() + { + // Per MS-DFSC: Domain referral request (\\DomainName) + Assert.AreEqual(0, (int)DfsRequestType.DomainReferral); + } + + [TestMethod] + public void DcReferral_HasCorrectValue() + { + // Per MS-DFSC: DC referral request (domain controller list) + Assert.AreEqual(1, (int)DfsRequestType.DcReferral); + } + + [TestMethod] + public void RootReferral_HasCorrectValue() + { + // Per MS-DFSC: Root referral request (\\Server\Share) + Assert.AreEqual(2, (int)DfsRequestType.RootReferral); + } + + [TestMethod] + public void SysvolReferral_HasCorrectValue() + { + // Per MS-DFSC: SYSVOL/NETLOGON referral request + Assert.AreEqual(3, (int)DfsRequestType.SysvolReferral); + } + + [TestMethod] + public void LinkReferral_HasCorrectValue() + { + // Per MS-DFSC: Link referral request (DFS folder link) + Assert.AreEqual(4, (int)DfsRequestType.LinkReferral); + } + + [TestMethod] + public void AllValues_AreParseable() + { + // Verify all enum values can be cast from integers + Assert.AreEqual(DfsRequestType.DomainReferral, (DfsRequestType)0); + Assert.AreEqual(DfsRequestType.DcReferral, (DfsRequestType)1); + Assert.AreEqual(DfsRequestType.RootReferral, (DfsRequestType)2); + Assert.AreEqual(DfsRequestType.SysvolReferral, (DfsRequestType)3); + Assert.AreEqual(DfsRequestType.LinkReferral, (DfsRequestType)4); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DfsResolverStateTests.cs b/SMBLibrary.Tests/DFS/DfsResolverStateTests.cs new file mode 100644 index 00000000..35ed90e4 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsResolverStateTests.cs @@ -0,0 +1,174 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.DFS +{ + /// + /// Unit tests for DfsResolverState class that tracks state through the + /// 14-step DFS resolution algorithm per MS-DFSC. + /// + [TestClass] + public class DfsResolverStateTests + { + [TestMethod] + public void Ctor_WithOriginalPath_SetsOriginalPath() + { + // Arrange & Act + var state = new DfsResolverState(@"\\domain.com\share\folder", "ctx"); + + // Assert + Assert.AreEqual(@"\\domain.com\share\folder", state.OriginalPath); + } + + [TestMethod] + public void Ctor_WithOriginalPath_SetsCurrentPathToOriginal() + { + // Arrange & Act + var state = new DfsResolverState(@"\\domain.com\share\folder", "ctx"); + + // Assert + Assert.AreEqual(@"\\domain.com\share\folder", state.CurrentPath); + } + + [TestMethod] + public void Ctor_WithContext_SetsContext() + { + // Arrange & Act + var context = new object(); + var state = new DfsResolverState(@"\\domain.com\share", context); + + // Assert + Assert.AreSame(context, state.Context); + } + + [TestMethod] + public void CurrentPath_CanBeUpdated() + { + // Arrange + var state = new DfsResolverState(@"\\domain.com\share\folder", "ctx"); + + // Act + state.CurrentPath = @"\\server1\share\folder"; + + // Assert + Assert.AreEqual(@"\\server1\share\folder", state.CurrentPath); + Assert.AreEqual(@"\\domain.com\share\folder", state.OriginalPath); // unchanged + } + + [TestMethod] + public void RequestType_DefaultIsNone() + { + // Arrange & Act + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Assert + Assert.IsFalse(state.RequestType.HasValue); + } + + [TestMethod] + public void RequestType_CanBeSet() + { + // Arrange + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Act + state.RequestType = DfsRequestType.RootReferral; + + // Assert + Assert.AreEqual(DfsRequestType.RootReferral, state.RequestType); + } + + [TestMethod] + public void IsComplete_DefaultIsFalse() + { + // Arrange & Act + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Assert + Assert.IsFalse(state.IsComplete); + } + + [TestMethod] + public void IsComplete_CanBeSetToTrue() + { + // Arrange + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Act + state.IsComplete = true; + + // Assert + Assert.IsTrue(state.IsComplete); + } + + [TestMethod] + public void IsDfsPath_DefaultIsFalse() + { + // Arrange & Act + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Assert + Assert.IsFalse(state.IsDfsPath); + } + + [TestMethod] + public void IsDfsPath_CanBeSetToTrue() + { + // Arrange + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Act + state.IsDfsPath = true; + + // Assert + Assert.IsTrue(state.IsDfsPath); + } + + [TestMethod] + public void CachedEntry_DefaultIsNull() + { + // Arrange & Act + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Assert + Assert.IsNull(state.CachedEntry); + } + + [TestMethod] + public void CachedEntry_CanBeSet() + { + // Arrange + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + var entry = new ReferralCacheEntry(@"\\domain.com\share"); + + // Act + state.CachedEntry = entry; + + // Assert + Assert.AreSame(entry, state.CachedEntry); + } + + [TestMethod] + public void LastStatus_DefaultIsSuccess() + { + // Arrange & Act + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Assert + Assert.AreEqual(NTStatus.STATUS_SUCCESS, state.LastStatus); + } + + [TestMethod] + public void LastStatus_CanBeSet() + { + // Arrange + var state = new DfsResolverState(@"\\domain.com\share", "ctx"); + + // Act + state.LastStatus = NTStatus.STATUS_PATH_NOT_COVERED; + + // Assert + Assert.AreEqual(NTStatus.STATUS_PATH_NOT_COVERED, state.LastStatus); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DfsServerTypeTests.cs b/SMBLibrary.Tests/DFS/DfsServerTypeTests.cs new file mode 100644 index 00000000..25e1a9c0 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DfsServerTypeTests.cs @@ -0,0 +1,24 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.DFS; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class DfsServerTypeTests + { + [TestMethod] + public void NonRoot_HasCorrectValue() + { + // MS-DFSC 2.2.4.x: NonRoot = 0x0000 + Assert.AreEqual((ushort)0x0000, (ushort)DfsServerType.NonRoot); + } + + [TestMethod] + public void Root_HasCorrectValue() + { + // MS-DFSC 2.2.4.x: Root = 0x0001 + Assert.AreEqual((ushort)0x0001, (ushort)DfsServerType.Root); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DomainCacheEntryTests.cs b/SMBLibrary.Tests/DFS/DomainCacheEntryTests.cs new file mode 100644 index 00000000..b42d0c23 --- /dev/null +++ b/SMBLibrary.Tests/DFS/DomainCacheEntryTests.cs @@ -0,0 +1,113 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using System; +using System.Collections.Generic; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class DomainCacheEntryTests + { + [TestMethod] + public void Ctor_WithDomainName_SetsDomainName() + { + // Arrange & Act + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + + // Assert + Assert.AreEqual("contoso.com", entry.DomainName); + } + + [TestMethod] + public void DcList_Default_IsEmpty() + { + // Arrange & Act + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + + // Assert + Assert.IsNotNull(entry.DcList); + Assert.AreEqual(0, entry.DcList.Count); + } + + [TestMethod] + public void DcList_AddDcs_ContainsDcs() + { + // Arrange + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + + // Act + entry.DcList.Add("DC1.contoso.com"); + entry.DcList.Add("DC2.contoso.com"); + + // Assert + Assert.AreEqual(2, entry.DcList.Count); + Assert.AreEqual("DC1.contoso.com", entry.DcList[0]); + } + + [TestMethod] + public void IsExpired_WhenExpiresUtcInPast_ReturnsTrue() + { + // Arrange + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); + + // Assert + Assert.IsTrue(entry.IsExpired); + } + + [TestMethod] + public void IsExpired_WhenExpiresUtcInFuture_ReturnsFalse() + { + // Arrange + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + + // Assert + Assert.IsFalse(entry.IsExpired); + } + + [TestMethod] + public void GetDcHint_WithDcs_ReturnsFirstDc() + { + // Arrange + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.DcList.Add("DC1.contoso.com"); + entry.DcList.Add("DC2.contoso.com"); + + // Act + string hint = entry.GetDcHint(); + + // Assert + Assert.AreEqual("DC1.contoso.com", hint); + } + + [TestMethod] + public void GetDcHint_NoDcs_ReturnsNull() + { + // Arrange + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + + // Act + string hint = entry.GetDcHint(); + + // Assert + Assert.IsNull(hint); + } + + [TestMethod] + public void NextDcHint_AdvancesToNextDc() + { + // Arrange + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.DcList.Add("DC1.contoso.com"); + entry.DcList.Add("DC2.contoso.com"); + + // Act + entry.NextDcHint(); + string hint = entry.GetDcHint(); + + // Assert + Assert.AreEqual("DC2.contoso.com", hint); + } + } +} diff --git a/SMBLibrary.Tests/DFS/DomainCacheTests.cs b/SMBLibrary.Tests/DFS/DomainCacheTests.cs new file mode 100644 index 00000000..8c67753a --- /dev/null +++ b/SMBLibrary.Tests/DFS/DomainCacheTests.cs @@ -0,0 +1,149 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using System; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class DomainCacheTests + { + [TestMethod] + public void Lookup_ExistingDomain_ReturnsEntry() + { + // Arrange + DomainCache cache = new DomainCache(); + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act + DomainCacheEntry result = cache.Lookup("contoso.com"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("contoso.com", result.DomainName); + } + + [TestMethod] + public void Lookup_ExpiredEntry_ReturnsNull() + { + // Arrange + DomainCache cache = new DomainCache(); + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); + cache.Add(entry); + + // Act + DomainCacheEntry result = cache.Lookup("contoso.com"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Lookup_NoMatch_ReturnsNull() + { + // Arrange + DomainCache cache = new DomainCache(); + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act + DomainCacheEntry result = cache.Lookup("fabrikam.com"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void ClearExpired_RemovesOnlyExpired() + { + // Arrange + DomainCache cache = new DomainCache(); + + DomainCacheEntry expired = new DomainCacheEntry("expired.com"); + expired.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); + cache.Add(expired); + + DomainCacheEntry valid = new DomainCacheEntry("valid.com"); + valid.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(valid); + + // Act + cache.ClearExpired(); + + // Assert + Assert.IsNull(cache.Lookup("expired.com")); + Assert.IsNotNull(cache.Lookup("valid.com")); + } + + [TestMethod] + public void Remove_ExistingDomain_RemovesIt() + { + // Arrange + DomainCache cache = new DomainCache(); + DomainCacheEntry entry = new DomainCacheEntry("contoso.com"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act + bool removed = cache.Remove("contoso.com"); + + // Assert + Assert.IsTrue(removed); + Assert.IsNull(cache.Lookup("contoso.com")); + } + + [TestMethod] + public void Remove_NonExistingDomain_ReturnsFalse() + { + // Arrange + DomainCache cache = new DomainCache(); + + // Act + bool removed = cache.Remove("contoso.com"); + + // Assert + Assert.IsFalse(removed); + } + + [TestMethod] + public void Clear_RemovesAllEntries() + { + // Arrange + DomainCache cache = new DomainCache(); + + DomainCacheEntry entry1 = new DomainCacheEntry("contoso.com"); + entry1.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry1); + + DomainCacheEntry entry2 = new DomainCacheEntry("fabrikam.com"); + entry2.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry2); + + // Act + cache.Clear(); + + // Assert + Assert.IsNull(cache.Lookup("contoso.com")); + Assert.IsNull(cache.Lookup("fabrikam.com")); + } + + [TestMethod] + public void Lookup_CaseInsensitive_FindsEntry() + { + // Arrange + DomainCache cache = new DomainCache(); + DomainCacheEntry entry = new DomainCacheEntry("CONTOSO.COM"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act + DomainCacheEntry result = cache.Lookup("contoso.com"); + + // Assert + Assert.IsNotNull(result); + } + } +} diff --git a/SMBLibrary.Tests/DFS/ReferralCacheEntryTests.cs b/SMBLibrary.Tests/DFS/ReferralCacheEntryTests.cs new file mode 100644 index 00000000..4be3eccb --- /dev/null +++ b/SMBLibrary.Tests/DFS/ReferralCacheEntryTests.cs @@ -0,0 +1,235 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using System; +using System.Collections.Generic; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class ReferralCacheEntryTests + { + [TestMethod] + public void Ctor_WithPathPrefix_SetsDfsPathPrefix() + { + // Arrange & Act + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Assert + Assert.AreEqual(@"\\contoso.com\dfs", entry.DfsPathPrefix); + } + + [TestMethod] + public void TtlSeconds_SetValue_ReturnsCorrectValue() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Act + entry.TtlSeconds = 300; + + // Assert + Assert.AreEqual((uint)300, entry.TtlSeconds); + } + + [TestMethod] + public void IsRoot_WhenRootOrLinkIsRoot_ReturnsTrue() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.RootOrLink = ReferralCacheEntryType.Root; + + // Assert + Assert.IsTrue(entry.IsRoot); + Assert.IsFalse(entry.IsLink); + } + + [TestMethod] + public void IsLink_WhenRootOrLinkIsLink_ReturnsTrue() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs\folder"); + entry.RootOrLink = ReferralCacheEntryType.Link; + + // Assert + Assert.IsTrue(entry.IsLink); + Assert.IsFalse(entry.IsRoot); + } + + [TestMethod] + public void IsExpired_WhenExpiresUtcInPast_ReturnsTrue() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); + + // Assert + Assert.IsTrue(entry.IsExpired); + } + + [TestMethod] + public void IsExpired_WhenExpiresUtcInFuture_ReturnsFalse() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(300); + + // Assert + Assert.IsFalse(entry.IsExpired); + } + + [TestMethod] + public void TargetList_Default_IsEmpty() + { + // Arrange & Act + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Assert + Assert.IsNotNull(entry.TargetList); + Assert.AreEqual(0, entry.TargetList.Count); + } + + [TestMethod] + public void TargetList_AddTargets_ContainsTargets() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + TargetSetEntry target1 = new TargetSetEntry(@"\\server1\share"); + TargetSetEntry target2 = new TargetSetEntry(@"\\server2\share"); + + // Act + entry.TargetList.Add(target1); + entry.TargetList.Add(target2); + + // Assert + Assert.AreEqual(2, entry.TargetList.Count); + Assert.AreEqual(@"\\server1\share", entry.TargetList[0].TargetPath); + Assert.AreEqual(@"\\server2\share", entry.TargetList[1].TargetPath); + } + + [TestMethod] + public void GetTargetHint_WithTargets_ReturnsFirstTarget() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.TargetList.Add(new TargetSetEntry(@"\\server1\share")); + entry.TargetList.Add(new TargetSetEntry(@"\\server2\share")); + + // Act + TargetSetEntry hint = entry.GetTargetHint(); + + // Assert + Assert.IsNotNull(hint); + Assert.AreEqual(@"\\server1\share", hint.TargetPath); + } + + [TestMethod] + public void GetTargetHint_NoTargets_ReturnsNull() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Act + TargetSetEntry hint = entry.GetTargetHint(); + + // Assert + Assert.IsNull(hint); + } + + [TestMethod] + public void NextTargetHint_AdvancesToNextTarget() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.TargetList.Add(new TargetSetEntry(@"\\server1\share")); + entry.TargetList.Add(new TargetSetEntry(@"\\server2\share")); + entry.TargetList.Add(new TargetSetEntry(@"\\server3\share")); + + // Act - advance to next target + entry.NextTargetHint(); + TargetSetEntry hint = entry.GetTargetHint(); + + // Assert + Assert.AreEqual(@"\\server2\share", hint.TargetPath); + } + + [TestMethod] + public void NextTargetHint_WrapsAroundToFirst() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.TargetList.Add(new TargetSetEntry(@"\\server1\share")); + entry.TargetList.Add(new TargetSetEntry(@"\\server2\share")); + + // Act - advance past end + entry.NextTargetHint(); + entry.NextTargetHint(); + TargetSetEntry hint = entry.GetTargetHint(); + + // Assert - should wrap to first + Assert.AreEqual(@"\\server1\share", hint.TargetPath); + } + + [TestMethod] + public void ResetTargetHint_ResetsToFirst() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.TargetList.Add(new TargetSetEntry(@"\\server1\share")); + entry.TargetList.Add(new TargetSetEntry(@"\\server2\share")); + entry.NextTargetHint(); // move to second + + // Act + entry.ResetTargetHint(); + TargetSetEntry hint = entry.GetTargetHint(); + + // Assert + Assert.AreEqual(@"\\server1\share", hint.TargetPath); + } + + [TestMethod] + public void IsInterlink_Default_IsFalse() + { + // Arrange & Act + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Assert + Assert.IsFalse(entry.IsInterlink); + } + + [TestMethod] + public void IsInterlink_SetTrue_ReturnsTrue() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Act + entry.IsInterlink = true; + + // Assert + Assert.IsTrue(entry.IsInterlink); + } + + [TestMethod] + public void TargetFailback_Default_IsFalse() + { + // Arrange & Act + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Assert + Assert.IsFalse(entry.TargetFailback); + } + + [TestMethod] + public void TargetFailback_SetTrue_ReturnsTrue() + { + // Arrange + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + + // Act + entry.TargetFailback = true; + + // Assert + Assert.IsTrue(entry.TargetFailback); + } + } +} diff --git a/SMBLibrary.Tests/DFS/ReferralCacheTests.cs b/SMBLibrary.Tests/DFS/ReferralCacheTests.cs new file mode 100644 index 00000000..ce222fa3 --- /dev/null +++ b/SMBLibrary.Tests/DFS/ReferralCacheTests.cs @@ -0,0 +1,165 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using System; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class ReferralCacheTests + { + [TestMethod] + public void Lookup_ExactMatch_ReturnsEntry() + { + // Arrange + ReferralCache cache = new ReferralCache(); + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act + ReferralCacheEntry result = cache.Lookup(@"\\contoso.com\dfs"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(@"\\contoso.com\dfs", result.DfsPathPrefix); + } + + [TestMethod] + public void Lookup_PrefixMatch_ReturnsLongestPrefix() + { + // Arrange + ReferralCache cache = new ReferralCache(); + + ReferralCacheEntry rootEntry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + rootEntry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(rootEntry); + + ReferralCacheEntry linkEntry = new ReferralCacheEntry(@"\\contoso.com\dfs\projects"); + linkEntry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(linkEntry); + + // Act - lookup a path under \dfs\projects + ReferralCacheEntry result = cache.Lookup(@"\\contoso.com\dfs\projects\alpha\readme.txt"); + + // Assert - should return the longest matching prefix + Assert.IsNotNull(result); + Assert.AreEqual(@"\\contoso.com\dfs\projects", result.DfsPathPrefix); + } + + [TestMethod] + public void Lookup_NoMatch_ReturnsNull() + { + // Arrange + ReferralCache cache = new ReferralCache(); + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act + ReferralCacheEntry result = cache.Lookup(@"\\other.com\share"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Lookup_ExpiredEntry_ReturnsNull() + { + // Arrange + ReferralCache cache = new ReferralCache(); + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); // expired + cache.Add(entry); + + // Act + ReferralCacheEntry result = cache.Lookup(@"\\contoso.com\dfs"); + + // Assert - expired entries should not be returned + Assert.IsNull(result); + } + + [TestMethod] + public void ClearExpired_RemovesOnlyExpired() + { + // Arrange + ReferralCache cache = new ReferralCache(); + + ReferralCacheEntry expiredEntry = new ReferralCacheEntry(@"\\contoso.com\expired"); + expiredEntry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); + cache.Add(expiredEntry); + + ReferralCacheEntry validEntry = new ReferralCacheEntry(@"\\contoso.com\valid"); + validEntry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(validEntry); + + // Act + cache.ClearExpired(); + + // Assert + Assert.IsNull(cache.Lookup(@"\\contoso.com\expired")); + Assert.IsNotNull(cache.Lookup(@"\\contoso.com\valid")); + } + + [TestMethod] + public void Remove_ExistingEntry_RemovesIt() + { + // Arrange + ReferralCache cache = new ReferralCache(); + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\contoso.com\dfs"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act + bool removed = cache.Remove(@"\\contoso.com\dfs"); + + // Assert + Assert.IsTrue(removed); + Assert.IsNull(cache.Lookup(@"\\contoso.com\dfs")); + } + + [TestMethod] + public void Remove_NonExistingEntry_ReturnsFalse() + { + // Arrange + ReferralCache cache = new ReferralCache(); + + // Act + bool removed = cache.Remove(@"\\contoso.com\dfs"); + + // Assert + Assert.IsFalse(removed); + } + + [TestMethod] + public void Clear_RemovesAllEntries() + { + // Arrange + ReferralCache cache = new ReferralCache(); + cache.Add(new ReferralCacheEntry(@"\\contoso.com\dfs") { ExpiresUtc = DateTime.UtcNow.AddMinutes(5) }); + cache.Add(new ReferralCacheEntry(@"\\contoso.com\other") { ExpiresUtc = DateTime.UtcNow.AddMinutes(5) }); + + // Act + cache.Clear(); + + // Assert + Assert.IsNull(cache.Lookup(@"\\contoso.com\dfs")); + Assert.IsNull(cache.Lookup(@"\\contoso.com\other")); + } + + [TestMethod] + public void Lookup_CaseInsensitive_FindsEntry() + { + // Arrange + ReferralCache cache = new ReferralCache(); + ReferralCacheEntry entry = new ReferralCacheEntry(@"\\CONTOSO.COM\DFS"); + entry.ExpiresUtc = DateTime.UtcNow.AddMinutes(5); + cache.Add(entry); + + // Act - lookup with different case + ReferralCacheEntry result = cache.Lookup(@"\\contoso.com\dfs"); + + // Assert + Assert.IsNotNull(result); + } + } +} diff --git a/SMBLibrary.Tests/DFS/ReferralCacheTreeTests.cs b/SMBLibrary.Tests/DFS/ReferralCacheTreeTests.cs new file mode 100644 index 00000000..c6201a89 --- /dev/null +++ b/SMBLibrary.Tests/DFS/ReferralCacheTreeTests.cs @@ -0,0 +1,342 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.Client.DFS; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class ReferralCacheTreeTests + { + #region Add and Lookup Tests + + [TestMethod] + public void Add_ThenLookup_ReturnsEntry() + { + // Arrange + var cache = new ReferralCacheTree(); + var entry = CreateEntry(@"\\domain\share", 300); + + // Act + cache.Add(entry); + var result = cache.Lookup(@"\\domain\share"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(entry.DfsPathPrefix, result.DfsPathPrefix); + } + + [TestMethod] + public void Lookup_WithSubpath_ReturnsLongestMatch() + { + // Arrange + var cache = new ReferralCacheTree(); + var rootEntry = CreateEntry(@"\\domain\dfs", 300); + var linkEntry = CreateEntry(@"\\domain\dfs\folder", 300); + + cache.Add(rootEntry); + cache.Add(linkEntry); + + // Act + var result = cache.Lookup(@"\\domain\dfs\folder\subdir\file.txt"); + + // Assert - should return the longer match + Assert.IsNotNull(result); + Assert.AreEqual(@"\\domain\dfs\folder", result.DfsPathPrefix); + } + + [TestMethod] + public void Lookup_WithPartialPath_ReturnsRootMatch() + { + // Arrange + var cache = new ReferralCacheTree(); + var rootEntry = CreateEntry(@"\\domain\dfs", 300); + var linkEntry = CreateEntry(@"\\domain\dfs\folder", 300); + + cache.Add(rootEntry); + cache.Add(linkEntry); + + // Act - lookup path that only matches root + var result = cache.Lookup(@"\\domain\dfs\other\file.txt"); + + // Assert - should return root match + Assert.IsNotNull(result); + Assert.AreEqual(@"\\domain\dfs", result.DfsPathPrefix); + } + + [TestMethod] + public void Lookup_WithNoMatch_ReturnsNull() + { + // Arrange + var cache = new ReferralCacheTree(); + var entry = CreateEntry(@"\\domain\share", 300); + cache.Add(entry); + + // Act + var result = cache.Lookup(@"\\other\path"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Lookup_WhenExpired_ReturnsNull() + { + // Arrange + var cache = new ReferralCacheTree(); + var entry = new ReferralCacheEntry(@"\\domain\share"); + entry.TtlSeconds = 1; + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); // Already expired + entry.TargetList.Add(new TargetSetEntry(@"\\server\share")); + + cache.Add(entry); + + // Act + var result = cache.Lookup(@"\\domain\share"); + + // Assert + Assert.IsNull(result, "Expired entries should not be returned"); + } + + [TestMethod] + public void Lookup_WithExpiredParent_ReturnsValidChild() + { + // Arrange + var cache = new ReferralCacheTree(); + + // Expired parent + var parentEntry = new ReferralCacheEntry(@"\\domain\dfs"); + parentEntry.TtlSeconds = 1; + parentEntry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); + parentEntry.TargetList.Add(new TargetSetEntry(@"\\server\dfs")); + + // Valid child + var childEntry = CreateEntry(@"\\domain\dfs\folder", 300); + + cache.Add(parentEntry); + cache.Add(childEntry); + + // Act + var result = cache.Lookup(@"\\domain\dfs\folder\file.txt"); + + // Assert - should skip expired parent, return valid child + Assert.IsNotNull(result); + Assert.AreEqual(@"\\domain\dfs\folder", result.DfsPathPrefix); + } + + #endregion + + #region Remove Tests + + [TestMethod] + public void Remove_ExistingEntry_ReturnsTrue() + { + // Arrange + var cache = new ReferralCacheTree(); + var entry = CreateEntry(@"\\domain\share", 300); + cache.Add(entry); + + // Act + bool removed = cache.Remove(@"\\domain\share"); + + // Assert + Assert.IsTrue(removed); + Assert.IsNull(cache.Lookup(@"\\domain\share")); + Assert.AreEqual(0, cache.Count); + } + + [TestMethod] + public void Remove_NonExistingEntry_ReturnsFalse() + { + // Arrange + var cache = new ReferralCacheTree(); + + // Act + bool removed = cache.Remove(@"\\domain\share"); + + // Assert + Assert.IsFalse(removed); + } + + [TestMethod] + public void Remove_ChildEntry_KeepsParent() + { + // Arrange + var cache = new ReferralCacheTree(); + var parentEntry = CreateEntry(@"\\domain\dfs", 300); + var childEntry = CreateEntry(@"\\domain\dfs\folder", 300); + + cache.Add(parentEntry); + cache.Add(childEntry); + + // Act + bool removed = cache.Remove(@"\\domain\dfs\folder"); + + // Assert + Assert.IsTrue(removed); + Assert.IsNotNull(cache.Lookup(@"\\domain\dfs")); + // After removing child, lookup for child path now returns parent (longest prefix match) + var result = cache.Lookup(@"\\domain\dfs\folder"); + Assert.IsNotNull(result, "Parent should still be returned as longest prefix match"); + Assert.AreEqual(@"\\domain\dfs", result.DfsPathPrefix); + Assert.AreEqual(1, cache.Count); + } + + #endregion + + #region Clear Tests + + [TestMethod] + public void Clear_RemovesAllEntries() + { + // Arrange + var cache = new ReferralCacheTree(); + cache.Add(CreateEntry(@"\\domain\dfs", 300)); + cache.Add(CreateEntry(@"\\domain\dfs\folder1", 300)); + cache.Add(CreateEntry(@"\\domain\dfs\folder2", 300)); + + // Act + cache.Clear(); + + // Assert + Assert.AreEqual(0, cache.Count); + Assert.IsNull(cache.Lookup(@"\\domain\dfs")); + } + + [TestMethod] + public void ClearExpired_RemovesOnlyExpiredEntries() + { + // Arrange + var cache = new ReferralCacheTree(); + + var validEntry = CreateEntry(@"\\domain\dfs\valid", 300); + + var expiredEntry = new ReferralCacheEntry(@"\\domain\dfs\expired"); + expiredEntry.TtlSeconds = 1; + expiredEntry.ExpiresUtc = DateTime.UtcNow.AddSeconds(-10); + expiredEntry.TargetList.Add(new TargetSetEntry(@"\\server\expired")); + + cache.Add(validEntry); + cache.Add(expiredEntry); + Assert.AreEqual(2, cache.Count); + + // Act + cache.ClearExpired(); + + // Assert + Assert.AreEqual(1, cache.Count); + Assert.IsNotNull(cache.Lookup(@"\\domain\dfs\valid")); + } + + #endregion + + #region Count Tests + + [TestMethod] + public void Count_ReflectsEntryCount() + { + // Arrange + var cache = new ReferralCacheTree(); + + // Act & Assert + Assert.AreEqual(0, cache.Count); + + cache.Add(CreateEntry(@"\\domain\share1", 300)); + Assert.AreEqual(1, cache.Count); + + cache.Add(CreateEntry(@"\\domain\share2", 300)); + Assert.AreEqual(2, cache.Count); + + cache.Remove(@"\\domain\share1"); + Assert.AreEqual(1, cache.Count); + } + + #endregion + + #region Edge Cases + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Add_NullEntry_ThrowsException() + { + var cache = new ReferralCacheTree(); + cache.Add(null); + } + + [TestMethod] + public void Lookup_NullPath_ReturnsNull() + { + var cache = new ReferralCacheTree(); + Assert.IsNull(cache.Lookup(null)); + } + + [TestMethod] + public void Lookup_EmptyPath_ReturnsNull() + { + var cache = new ReferralCacheTree(); + Assert.IsNull(cache.Lookup(string.Empty)); + } + + [TestMethod] + public void Add_SamePathTwice_UpdatesEntry() + { + // Arrange + var cache = new ReferralCacheTree(); + var entry1 = CreateEntry(@"\\domain\share", 300); + var entry2 = CreateEntry(@"\\domain\share", 600); + + // Act + cache.Add(entry1); + cache.Add(entry2); + + // Assert - should have updated, not duplicated + Assert.AreEqual(1, cache.Count); + var result = cache.Lookup(@"\\domain\share"); + Assert.AreEqual(600u, result.TtlSeconds); + } + + [TestMethod] + public void Lookup_CaseInsensitive() + { + // Arrange + var cache = new ReferralCacheTree(); + var entry = CreateEntry(@"\\DOMAIN\SHARE", 300); + cache.Add(entry); + + // Act & Assert - should match regardless of case + Assert.IsNotNull(cache.Lookup(@"\\domain\share")); + Assert.IsNotNull(cache.Lookup(@"\\DOMAIN\SHARE")); + Assert.IsNotNull(cache.Lookup(@"\\Domain\Share")); + } + + [TestMethod] + public void Lookup_HandlesForwardSlashes() + { + // Arrange + var cache = new ReferralCacheTree(); + var entry = CreateEntry(@"\\domain\share\folder", 300); + cache.Add(entry); + + // Act - lookup with forward slashes + var result = cache.Lookup("//domain/share/folder/file.txt"); + + // Assert + Assert.IsNotNull(result); + } + + #endregion + + #region Helpers + + private static ReferralCacheEntry CreateEntry(string prefix, uint ttlSeconds) + { + var entry = new ReferralCacheEntry(prefix); + entry.TtlSeconds = ttlSeconds; + entry.ExpiresUtc = DateTime.UtcNow.AddSeconds(ttlSeconds); + entry.TargetList.Add(new TargetSetEntry(@"\\server\target")); + return entry; + } + + #endregion + } +} diff --git a/SMBLibrary.Tests/DFS/TargetSetEntryTests.cs b/SMBLibrary.Tests/DFS/TargetSetEntryTests.cs new file mode 100644 index 00000000..d8373254 --- /dev/null +++ b/SMBLibrary.Tests/DFS/TargetSetEntryTests.cs @@ -0,0 +1,89 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SMBLibrary; +using SMBLibrary.DFS; + +namespace SMBLibrary.Tests.DFS +{ + [TestClass] + public class TargetSetEntryTests + { + [TestMethod] + public void Ctor_WithTargetPath_SetsTargetPath() + { + // Arrange & Act + TargetSetEntry entry = new TargetSetEntry(@"\\server\share"); + + // Assert + Assert.AreEqual(@"\\server\share", entry.TargetPath); + } + + [TestMethod] + public void Priority_Default_IsZero() + { + // Arrange & Act + TargetSetEntry entry = new TargetSetEntry(@"\\server\share"); + + // Assert + Assert.AreEqual(0, entry.Priority); + } + + [TestMethod] + public void Priority_SetValue_ReturnsCorrectValue() + { + // Arrange + TargetSetEntry entry = new TargetSetEntry(@"\\server\share"); + + // Act + entry.Priority = 5; + + // Assert + Assert.AreEqual(5, entry.Priority); + } + + [TestMethod] + public void IsTargetSetBoundary_Default_IsFalse() + { + // Arrange & Act + TargetSetEntry entry = new TargetSetEntry(@"\\server\share"); + + // Assert + Assert.IsFalse(entry.IsTargetSetBoundary); + } + + [TestMethod] + public void IsTargetSetBoundary_SetTrue_ReturnsTrue() + { + // Arrange + TargetSetEntry entry = new TargetSetEntry(@"\\server\share"); + + // Act + entry.IsTargetSetBoundary = true; + + // Assert + Assert.IsTrue(entry.IsTargetSetBoundary); + } + + [TestMethod] + public void ServerType_Default_IsNonRoot() + { + // Arrange & Act + TargetSetEntry entry = new TargetSetEntry(@"\\server\share"); + + // Assert + Assert.AreEqual(DfsServerType.NonRoot, entry.ServerType); + } + + [TestMethod] + public void ServerType_SetRoot_ReturnsRoot() + { + // Arrange + TargetSetEntry entry = new TargetSetEntry(@"\\server\share"); + + // Act + entry.ServerType = DfsServerType.Root; + + // Assert + Assert.AreEqual(DfsServerType.Root, entry.ServerType); + } + } +} diff --git a/SMBLibrary.Tests/TestData/DFS/ReqGetDfsReferralEx-Request.bin b/SMBLibrary.Tests/TestData/DFS/ReqGetDfsReferralEx-Request.bin new file mode 100644 index 00000000..63add4ea Binary files /dev/null and b/SMBLibrary.Tests/TestData/DFS/ReqGetDfsReferralEx-Request.bin differ diff --git a/SMBLibrary.Tests/TestData/DFS/RespGetDfsReferral-Response.bin b/SMBLibrary.Tests/TestData/DFS/RespGetDfsReferral-Response.bin new file mode 100644 index 00000000..322e8482 Binary files /dev/null and b/SMBLibrary.Tests/TestData/DFS/RespGetDfsReferral-Response.bin differ diff --git a/SMBLibrary.Tests/TestData/DFS/dfs-referral-ex-capture.pcapng b/SMBLibrary.Tests/TestData/DFS/dfs-referral-ex-capture.pcapng new file mode 100644 index 00000000..e9e436a3 Binary files /dev/null and b/SMBLibrary.Tests/TestData/DFS/dfs-referral-ex-capture.pcapng differ diff --git a/SMBLibrary/Client/DFS/DfsAwareClientAdapter.cs b/SMBLibrary/Client/DFS/DfsAwareClientAdapter.cs new file mode 100644 index 00000000..ecb40b09 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsAwareClientAdapter.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using SMBLibrary; +using SMBLibrary.Client; + +namespace SMBLibrary.Client.DFS +{ + /// + /// DFS-aware ISMBFileStore adapter that composes an ISMBFileStore with an IDfsClientResolver. + /// Implements reactive DFS resolution by catching STATUS_PATH_NOT_COVERED and retrying + /// with resolved paths per MS-DFSC section 3.1.5.1. + /// + public class DfsAwareClientAdapter : ISMBFileStore + { + private readonly ISMBFileStore _inner; + private readonly IDfsClientResolver _resolver; + private readonly DfsClientOptions _options; + private readonly int _maxRetries; + + /// + /// Maximum number of DFS resolution retries (to prevent infinite loops on interlinks). + /// + private const int DefaultMaxRetries = 3; + + public DfsAwareClientAdapter(ISMBFileStore inner, IDfsClientResolver resolver, DfsClientOptions options) + : this(inner, resolver, options, DefaultMaxRetries) + { + } + + public DfsAwareClientAdapter(ISMBFileStore inner, IDfsClientResolver resolver, DfsClientOptions options, int maxRetries) + { + if (inner == null) + { + throw new ArgumentNullException("inner"); + } + + if (resolver == null) + { + throw new ArgumentNullException("resolver"); + } + + if (options == null) + { + throw new ArgumentNullException("options"); + } + + _inner = inner; + _resolver = resolver; + _options = options; + _maxRetries = maxRetries > 0 ? maxRetries : DefaultMaxRetries; + } + + /// + /// Resolves a path using the DFS resolver. Returns the original path if resolution fails. + /// + private string ResolvePath(string path) + { + if (path == null) + { + return null; + } + + DfsResolutionResult result = _resolver.Resolve(_options, path); + if (result == null) + { + return path; + } + + if (result.Status == DfsResolutionStatus.Success && !String.IsNullOrEmpty(result.ResolvedPath)) + { + return result.ResolvedPath; + } + + if (!String.IsNullOrEmpty(result.OriginalPath)) + { + return result.OriginalPath; + } + + if (!String.IsNullOrEmpty(result.ResolvedPath)) + { + return result.ResolvedPath; + } + + return path; + } + + /// + /// Attempts to resolve a path after receiving STATUS_PATH_NOT_COVERED. + /// Forces a fresh DFS referral lookup, bypassing any cached entries. + /// + private string ResolvePathAfterNotCovered(string originalPath) + { + // Force resolution - the path wasn't covered by current target + DfsResolutionResult result = _resolver.Resolve(_options, originalPath); + if (result != null && result.Status == DfsResolutionStatus.Success && !String.IsNullOrEmpty(result.ResolvedPath)) + { + return result.ResolvedPath; + } + + // If resolution fails, return null to indicate no retry should occur + return null; + } + + /// + /// Checks if the status indicates a DFS redirect is needed. + /// + private static bool IsDfsRedirectStatus(NTStatus status) + { + return status == NTStatus.STATUS_PATH_NOT_COVERED; + } + + public NTStatus CreateFile(out object handle, out FileStatus fileStatus, string path, AccessMask desiredAccess, FileAttributes fileAttributes, ShareAccess shareAccess, CreateDisposition createDisposition, CreateOptions createOptions, SecurityContext securityContext) + { + string effectivePath = ResolvePath(path); + int retryCount = 0; + + while (true) + { + NTStatus status = _inner.CreateFile(out handle, out fileStatus, effectivePath, desiredAccess, fileAttributes, shareAccess, createDisposition, createOptions, securityContext); + + // If successful or not a DFS redirect, return immediately + if (status == NTStatus.STATUS_SUCCESS || !IsDfsRedirectStatus(status)) + { + return status; + } + + // STATUS_PATH_NOT_COVERED: attempt DFS resolution and retry + retryCount++; + if (retryCount > _maxRetries) + { + // Max retries exceeded - return the error + return status; + } + + string resolvedPath = ResolvePathAfterNotCovered(path); + if (resolvedPath == null || String.Equals(resolvedPath, effectivePath, StringComparison.OrdinalIgnoreCase)) + { + // Resolution failed or returned same path - no point retrying + return status; + } + + // Retry with resolved path + effectivePath = resolvedPath; + } + } + + public NTStatus CloseFile(object handle) + { + return _inner.CloseFile(handle); + } + + public NTStatus ReadFile(out byte[] data, object handle, long offset, int maxCount) + { + return _inner.ReadFile(out data, handle, offset, maxCount); + } + + public NTStatus WriteFile(out int numberOfBytesWritten, object handle, long offset, byte[] data) + { + return _inner.WriteFile(out numberOfBytesWritten, handle, offset, data); + } + + public NTStatus FlushFileBuffers(object handle) + { + return _inner.FlushFileBuffers(handle); + } + + public NTStatus LockFile(object handle, long byteOffset, long length, bool exclusiveLock) + { + return _inner.LockFile(handle, byteOffset, length, exclusiveLock); + } + + public NTStatus UnlockFile(object handle, long byteOffset, long length) + { + return _inner.UnlockFile(handle, byteOffset, length); + } + + public NTStatus QueryDirectory(out List result, object handle, string fileName, FileInformationClass informationClass) + { + string effectiveFileName = ResolvePath(fileName); + return _inner.QueryDirectory(out result, handle, effectiveFileName, informationClass); + } + + public NTStatus GetFileInformation(out FileInformation result, object handle, FileInformationClass informationClass) + { + return _inner.GetFileInformation(out result, handle, informationClass); + } + + public NTStatus SetFileInformation(object handle, FileInformation information) + { + return _inner.SetFileInformation(handle, information); + } + + public NTStatus GetFileSystemInformation(out FileSystemInformation result, FileSystemInformationClass informationClass) + { + return _inner.GetFileSystemInformation(out result, informationClass); + } + + public NTStatus SetFileSystemInformation(FileSystemInformation information) + { + return _inner.SetFileSystemInformation(information); + } + + public NTStatus GetSecurityInformation(out SecurityDescriptor result, object handle, SecurityInformation securityInformation) + { + return _inner.GetSecurityInformation(out result, handle, securityInformation); + } + + public NTStatus SetSecurityInformation(object handle, SecurityInformation securityInformation, SecurityDescriptor securityDescriptor) + { + return _inner.SetSecurityInformation(handle, securityInformation, securityDescriptor); + } + + public NTStatus NotifyChange(out object ioRequest, object handle, NotifyChangeFilter completionFilter, bool watchTree, int outputBufferSize, OnNotifyChangeCompleted onNotifyChangeCompleted, object context) + { + return _inner.NotifyChange(out ioRequest, handle, completionFilter, watchTree, outputBufferSize, onNotifyChangeCompleted, context); + } + + public NTStatus Cancel(object ioRequest) + { + return _inner.Cancel(ioRequest); + } + + public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) + { + return _inner.DeviceIOControl(handle, ctlCode, input, out output, maxOutputLength); + } + + public NTStatus Disconnect() + { + return _inner.Disconnect(); + } + + public uint MaxReadSize + { + get { return _inner.MaxReadSize; } + } + + public uint MaxWriteSize + { + get { return _inner.MaxWriteSize; } + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsClientFactory.cs b/SMBLibrary/Client/DFS/DfsClientFactory.cs new file mode 100644 index 00000000..479c2a16 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsClientFactory.cs @@ -0,0 +1,35 @@ +using System; +using SMBLibrary.Client; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Public entry point for composing a DFS-aware ISMBFileStore from an existing ISMBFileStore + /// and DFS client options, without changing existing SMB2Client / ISMBClient APIs. + /// + public static class DfsClientFactory + { + /// + /// Create a DFS-aware ISMBFileStore when DFS is enabled via options; otherwise return the + /// original store unchanged. + /// + /// The underlying file store to wrap. + /// An optional DFS IOCTL handle; may be null depending on transport implementation. + /// DFS client options controlling whether DFS is enabled. + public static ISMBFileStore CreateDfsAwareFileStore(ISMBFileStore innerStore, object dfsHandle, DfsClientOptions options) + { + if (innerStore == null) + { + throw new ArgumentNullException("innerStore"); + } + + // No options or DFS disabled: behave as a simple pass-through. + if (options == null || !options.Enabled) + { + return innerStore; + } + + return DfsFileStoreFactory.CreateDfsAwareFileStore(innerStore, dfsHandle, options); + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsClientOptions.cs b/SMBLibrary/Client/DFS/DfsClientOptions.cs new file mode 100644 index 00000000..1ea4e3a0 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsClientOptions.cs @@ -0,0 +1,74 @@ +namespace SMBLibrary.Client.DFS +{ + /// + /// DFS client options for controlling DFS-related behavior on a per-client or per-connection basis. + /// For vNext-DFS, DFS is disabled by default. + /// + public class DfsClientOptions + { + private int _referralCacheTtlSeconds = 300; + private int _domainCacheTtlSeconds = 300; + private int _maxRetries = 3; + + /// + /// When false (default), DFS client behavior is disabled and no DFS referral requests are issued. + /// When true, DFS-related features may be enabled for the associated client/connection. + /// + public bool Enabled { get; set; } + + /// + /// When true, enables domain cache for domain-based DFS resolution (requires domain environment). + /// Default: false. + /// + public bool EnableDomainCache { get; set; } + + /// + /// When true, enables the full 14-step DFS resolution algorithm per MS-DFSC. + /// When false, uses a simpler single-request resolution. + /// Default: false. + /// + public bool EnableFullResolution { get; set; } + + /// + /// When true, enables cross-server session management for DFS interlink scenarios. + /// Default: false. + /// + public bool EnableCrossServerSessions { get; set; } + + /// + /// Gets or sets the TTL in seconds for referral cache entries. + /// Default: 300 seconds (5 minutes). + /// + public int ReferralCacheTtlSeconds + { + get { return _referralCacheTtlSeconds; } + set { _referralCacheTtlSeconds = value; } + } + + /// + /// Gets or sets the TTL in seconds for domain cache entries. + /// Default: 300 seconds (5 minutes). + /// + public int DomainCacheTtlSeconds + { + get { return _domainCacheTtlSeconds; } + set { _domainCacheTtlSeconds = value; } + } + + /// + /// Gets or sets the maximum number of retries for DFS target failover. + /// Default: 3. + /// + public int MaxRetries + { + get { return _maxRetries; } + set { _maxRetries = value; } + } + + /// + /// Gets or sets the site name for site-aware referral requests. + /// When null (default), no site name is sent in referral requests. + /// + public string SiteName { get; set; } + } +} diff --git a/SMBLibrary/Client/DFS/DfsClientResolver.cs b/SMBLibrary/Client/DFS/DfsClientResolver.cs new file mode 100644 index 00000000..547616c2 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsClientResolver.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using SMBLibrary; +using SMBLibrary.DFS; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Default DFS client resolver implementation for vNext-DFS. + /// For now, it only handles the DFS-disabled case and returns NotApplicable. + /// + public class DfsClientResolver : IDfsClientResolver + { + private readonly IDfsReferralTransport _transport; + private readonly Dictionary _cache; + + private class CachedReferral + { + public string ResolvedPath; + public DateTime ExpirationUtc; + } + + public DfsClientResolver() + { + _transport = null; + _cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public DfsClientResolver(IDfsReferralTransport transport) + { + _transport = transport; + _cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public DfsResolutionResult Resolve(DfsClientOptions options, string originalPath) + { + if (options == null) + { + throw new ArgumentNullException("options"); + } + + if (!options.Enabled) + { + return CreateResult(originalPath, DfsResolutionStatus.NotApplicable); + } + + if (_transport == null) + { + return CreateResult(originalPath, DfsResolutionStatus.Error); + } + + DfsResolutionResult cachedResult = TryGetFromCache(originalPath); + if (cachedResult != null) + { + return cachedResult; + } + + return ResolveViaTransport(originalPath); + } + + private DfsResolutionResult TryGetFromCache(string originalPath) + { + if (String.IsNullOrEmpty(originalPath)) + { + return null; + } + + CachedReferral cached; + if (!_cache.TryGetValue(originalPath, out cached)) + { + return null; + } + + if (cached.ExpirationUtc < DateTime.UtcNow) + { + _cache.Remove(originalPath); + return null; + } + + return CreateResult(originalPath, DfsResolutionStatus.Success, cached.ResolvedPath); + } + + private DfsResolutionResult ResolveViaTransport(string originalPath) + { + byte[] buffer; + uint outputCount; + NTStatus status = _transport.TryGetReferrals(null, originalPath, 0, out buffer, out outputCount); + + if (status == NTStatus.STATUS_FS_DRIVER_REQUIRED) + { + return CreateResult(originalPath, DfsResolutionStatus.NotApplicable); + } + + if (status != NTStatus.STATUS_SUCCESS || buffer == null) + { + return CreateResult(originalPath, DfsResolutionStatus.Error); + } + + return ParseAndCacheResponse(originalPath, buffer, outputCount); + } + + private DfsResolutionResult ParseAndCacheResponse(string originalPath, byte[] buffer, uint outputCount) + { + byte[] effectiveBuffer = GetEffectiveBuffer(buffer, outputCount); + ResponseGetDfsReferral response = new ResponseGetDfsReferral(effectiveBuffer); + + if (response.ReferralEntries == null || response.ReferralEntries.Count == 0) + { + return CreateResult(originalPath, DfsResolutionStatus.Error); + } + + string resolvedPath = DfsReferralSelector.SelectResolvedPath(originalPath, response.PathConsumed, response.ReferralEntries.ToArray()); + if (String.IsNullOrEmpty(resolvedPath)) + { + return CreateResult(originalPath, DfsResolutionStatus.Error); + } + + CacheResult(originalPath, resolvedPath, response); + return CreateResult(originalPath, DfsResolutionStatus.Success, resolvedPath); + } + + private byte[] GetEffectiveBuffer(byte[] buffer, uint outputCount) + { + if (outputCount > 0 && outputCount < (uint)buffer.Length) + { + byte[] effectiveBuffer = new byte[outputCount]; + Array.Copy(buffer, effectiveBuffer, (int)outputCount); + return effectiveBuffer; + } + return buffer; + } + + private void CacheResult(string originalPath, string resolvedPath, ResponseGetDfsReferral response) + { + if (String.IsNullOrEmpty(originalPath)) + { + return; + } + + uint ttlSeconds = GetTimeToLive(response.ReferralEntries[0]); + + if (ttlSeconds > 0) + { + CachedReferral cached = new CachedReferral(); + cached.ResolvedPath = resolvedPath; + cached.ExpirationUtc = DateTime.UtcNow.AddSeconds(ttlSeconds); + _cache[originalPath] = cached; + } + } + + private static uint GetTimeToLive(DfsReferralEntry entry) + { + // V3/V4 have TimeToLive + DfsReferralEntryV3 v3 = entry as DfsReferralEntryV3; + if (v3 != null) + { + return v3.TimeToLive; + } + + // V2 has TimeToLive + DfsReferralEntryV2 v2 = entry as DfsReferralEntryV2; + if (v2 != null) + { + return v2.TimeToLive; + } + + // V1 has no TimeToLive - use default + return 300; + } + + private DfsResolutionResult CreateResult(string originalPath, DfsResolutionStatus status) + { + return CreateResult(originalPath, status, originalPath); + } + + private DfsResolutionResult CreateResult(string originalPath, DfsResolutionStatus status, string resolvedPath) + { + DfsResolutionResult result = new DfsResolutionResult(); + result.OriginalPath = originalPath; + result.Status = status; + result.ResolvedPath = resolvedPath; + return result; + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsCredentials.cs b/SMBLibrary/Client/DFS/DfsCredentials.cs new file mode 100644 index 00000000..c949967b --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsCredentials.cs @@ -0,0 +1,48 @@ +using System; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Holds credentials for DFS client authentication when connecting to referred servers. + /// + public class DfsCredentials + { + /// + /// Gets the domain name. + /// + public string DomainName { get; private set; } + + /// + /// Gets the user name. + /// + public string UserName { get; private set; } + + /// + /// Gets the password. + /// + public string Password { get; private set; } + + /// + /// Initializes a new instance of . + /// + /// The domain name. + /// The user name. + /// The password. + public DfsCredentials(string domainName, string userName, string password) + { + if (userName == null) + { + throw new ArgumentNullException("userName"); + } + + if (password == null) + { + throw new ArgumentNullException("password"); + } + + DomainName = domainName; + UserName = userName; + Password = password; + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsEvents.cs b/SMBLibrary/Client/DFS/DfsEvents.cs new file mode 100644 index 00000000..310bf69c --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsEvents.cs @@ -0,0 +1,140 @@ +using System; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Event args for when DFS path resolution starts. + /// + public class DfsResolutionStartedEventArgs : EventArgs + { + /// + /// Initializes a new instance of . + /// + /// The UNC path being resolved. + public DfsResolutionStartedEventArgs(string path) + { + Path = path; + } + + /// + /// Gets the UNC path being resolved. + /// + public string Path { get; private set; } + } + + /// + /// Event args for when a DFS referral request is issued. + /// + public class DfsReferralRequestedEventArgs : EventArgs + { + /// + /// Initializes a new instance of . + /// + /// The path for which referral is requested. + /// The type of referral request. + /// The server receiving the request, if known. + public DfsReferralRequestedEventArgs(string path, DfsRequestType requestType, string targetServer) + { + Path = path; + RequestType = requestType; + TargetServer = targetServer; + } + + /// + /// Gets the path for which referral is requested. + /// + public string Path { get; private set; } + + /// + /// Gets the type of referral request. + /// + public DfsRequestType RequestType { get; private set; } + + /// + /// Gets the server receiving the request, if known. + /// + public string TargetServer { get; private set; } + } + + /// + /// Event args for when a DFS referral response is received. + /// + public class DfsReferralReceivedEventArgs : EventArgs + { + /// + /// Initializes a new instance of . + /// + /// The path for which referral was requested. + /// The status from the referral response. + /// The number of referral entries received. + /// The TTL in seconds from the first referral entry. + public DfsReferralReceivedEventArgs(string path, NTStatus status, int referralCount, int ttlSeconds) + { + Path = path; + Status = status; + ReferralCount = referralCount; + TtlSeconds = ttlSeconds; + } + + /// + /// Gets the path for which referral was requested. + /// + public string Path { get; private set; } + + /// + /// Gets the status from the referral response. + /// + public NTStatus Status { get; private set; } + + /// + /// Gets the number of referral entries received. + /// + public int ReferralCount { get; private set; } + + /// + /// Gets the TTL in seconds from the first referral entry. + /// + public int TtlSeconds { get; private set; } + } + + /// + /// Event args for when DFS path resolution completes. + /// + public class DfsResolutionCompletedEventArgs : EventArgs + { + /// + /// Initializes a new instance of . + /// + /// The original UNC path requested. + /// The resolved UNC path. + /// The final status of resolution. + /// Whether the path was a DFS path. + public DfsResolutionCompletedEventArgs(string originalPath, string resolvedPath, NTStatus status, bool wasDfsPath) + { + OriginalPath = originalPath; + ResolvedPath = resolvedPath; + Status = status; + WasDfsPath = wasDfsPath; + } + + /// + /// Gets the original UNC path requested. + /// + public string OriginalPath { get; private set; } + + /// + /// Gets the resolved UNC path. + /// + public string ResolvedPath { get; private set; } + + /// + /// Gets the final status of resolution. + /// + public NTStatus Status { get; private set; } + + /// + /// Gets whether the path was a DFS path. + /// + public bool WasDfsPath { get; private set; } + } +} diff --git a/SMBLibrary/Client/DFS/DfsException.cs b/SMBLibrary/Client/DFS/DfsException.cs new file mode 100644 index 00000000..544cc6b4 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsException.cs @@ -0,0 +1,67 @@ +using System; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Exception thrown when DFS path resolution fails. + /// + /// + /// This exception is intended for use in scenarios where callers prefer exception-based + /// error handling over status-code returns. The currently + /// uses status-based returns via , but this exception + /// can be thrown by higher-level wrappers or future API extensions. + /// + public class DfsException : Exception + { + /// + /// Initializes a new instance of with a message. + /// + /// The error message. + public DfsException(string message) : base(message) + { + Status = NTStatus.STATUS_SUCCESS; + } + + /// + /// Initializes a new instance of with a message and inner exception. + /// + /// The error message. + /// The inner exception. + public DfsException(string message, Exception innerException) : base(message, innerException) + { + Status = NTStatus.STATUS_SUCCESS; + } + + /// + /// Initializes a new instance of with a message and status. + /// + /// The error message. + /// The NTStatus code from the failed operation. + public DfsException(string message, NTStatus status) : base(message) + { + Status = status; + } + + /// + /// Initializes a new instance of with a message, status, and path. + /// + /// The error message. + /// The NTStatus code from the failed operation. + /// The DFS path that failed to resolve. + public DfsException(string message, NTStatus status, string path) : base(message) + { + Status = status; + Path = path; + } + + /// + /// Gets the NTStatus code from the failed DFS operation. + /// + public NTStatus Status { get; private set; } + + /// + /// Gets the DFS path that failed to resolve, if available. + /// + public string Path { get; private set; } + } +} diff --git a/SMBLibrary/Client/DFS/DfsFileStoreFactory.cs b/SMBLibrary/Client/DFS/DfsFileStoreFactory.cs new file mode 100644 index 00000000..d29a275b --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsFileStoreFactory.cs @@ -0,0 +1,35 @@ +using System; +using SMBLibrary; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Internal helper for composing a DFS-aware ISMBFileStore from an existing ISMBFileStore, + /// a DFS handle, and DFS client options. + /// + internal static class DfsFileStoreFactory + { + internal static ISMBFileStore CreateDfsAwareFileStore(ISMBFileStore inner, object dfsHandle, DfsClientOptions options) + { + if (inner == null) + { + throw new ArgumentNullException("inner"); + } + + if (options == null) + { + throw new ArgumentNullException("options"); + } + + // If DFS is disabled, return the original store unchanged. + if (!options.Enabled) + { + return inner; + } + + IDfsReferralTransport transport = Smb2DfsReferralTransport.CreateUsingDeviceIOControl(inner, dfsHandle); + IDfsClientResolver resolver = new DfsClientResolver(transport); + return new DfsAwareClientAdapter(inner, resolver, options); + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsIoctlRequestBuilder.cs b/SMBLibrary/Client/DFS/DfsIoctlRequestBuilder.cs new file mode 100644 index 00000000..fb7d19f9 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsIoctlRequestBuilder.cs @@ -0,0 +1,90 @@ +using System; +using SMBLibrary; +using SMBLibrary.DFS; +using SMBLibrary.SMB2; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Helper for constructing DFS-related SMB2 IOCTL requests. + /// + internal static class DfsIoctlRequestBuilder + { + /// + /// Special FileID used for DFS referral IOCTLs that don't require an open file handle. + /// Per MS-SMB2 2.2.31, this value (0xFFFFFFFFFFFFFFFF for both parts) indicates + /// the FSCTL doesn't need an associated file. + /// + public static readonly FileID DfsReferralFileId = new FileID + { + Persistent = 0xFFFFFFFFFFFFFFFFUL, + Volatile = 0xFFFFFFFFFFFFFFFFUL + }; + + /// + /// Create an SMB2 IOCTL request for FSCTL_DFS_GET_REFERRALS. + /// This helper does not send the request; it only sets up the command. + /// + /// The DFS path to request referrals for (UNC-style). + /// Maximum response size the client is prepared to accept. + internal static IOCtlRequest CreateDfsReferralRequest(string dfsPath, uint maxOutputResponse) + { + if (dfsPath == null) + { + throw new ArgumentNullException("dfsPath"); + } + + // Build DFSC request payload + RequestGetDfsReferral dfsRequest = new RequestGetDfsReferral(); + dfsRequest.MaxReferralLevel = 4; // Request V4 referrals for maximum interop + dfsRequest.RequestFileName = dfsPath; + byte[] inputBuffer = dfsRequest.GetBytes(); + + IOCtlRequest request = new IOCtlRequest(); + request.CtlCode = (uint)IoControlCode.FSCTL_DFS_GET_REFERRALS; + request.FileId = DfsReferralFileId; + request.IsFSCtl = true; + request.MaxOutputResponse = maxOutputResponse; + request.Input = inputBuffer; + request.Output = new byte[0]; + + return request; + } + + /// + /// Create an SMB2 IOCTL request for FSCTL_DFS_GET_REFERRALS_EX with optional site name. + /// This helper does not send the request; it only sets up the command. + /// + /// The DFS path to request referrals for (UNC-style). + /// Optional site name for site-aware referral requests. Pass null if not used. + /// Maximum response size the client is prepared to accept. + internal static IOCtlRequest CreateDfsReferralRequestEx(string dfsPath, string siteName, uint maxOutputResponse) + { + if (dfsPath == null) + { + throw new ArgumentNullException("dfsPath"); + } + + // Build DFSC extended request payload + RequestGetDfsReferralEx dfsRequest = new RequestGetDfsReferralEx(); + dfsRequest.MaxReferralLevel = 4; // Request V4 referrals for maximum interop + dfsRequest.RequestFileName = dfsPath; + if (!string.IsNullOrEmpty(siteName)) + { + dfsRequest.Flags = RequestGetDfsReferralExFlags.SiteName; + dfsRequest.SiteName = siteName; + } + byte[] inputBuffer = dfsRequest.GetBytes(); + + IOCtlRequest request = new IOCtlRequest(); + request.CtlCode = (uint)IoControlCode.FSCTL_DFS_GET_REFERRALS_EX; + request.FileId = DfsReferralFileId; + request.IsFSCtl = true; + request.MaxOutputResponse = maxOutputResponse; + request.Input = inputBuffer; + request.Output = new byte[0]; + + return request; + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsPathResolver.cs b/SMBLibrary/Client/DFS/DfsPathResolver.cs new file mode 100644 index 00000000..6e8d005f --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsPathResolver.cs @@ -0,0 +1,366 @@ +using System; +using SMBLibrary.DFS; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Implements the 14-step DFS path resolution algorithm per MS-DFSC section 3.1.4.2. + /// Uses DfsPath for path manipulation, ReferralCache and DomainCache for caching. + /// + public class DfsPathResolver + { + private readonly ReferralCache _referralCache; + private readonly DomainCache _domainCache; + private readonly IDfsReferralTransport _transport; + + /// + /// Raised when DFS resolution starts. + /// + public event EventHandler ResolutionStarted; + + /// + /// Raised when a DFS referral request is issued. + /// + public event EventHandler ReferralRequested; + + /// + /// Raised when a DFS referral response is received. + /// + public event EventHandler ReferralReceived; + + /// + /// Raised when DFS resolution completes. + /// + public event EventHandler ResolutionCompleted; + + /// + /// Initializes a new instance of with no caches or transport. + /// + public DfsPathResolver() + : this(null, null, null) + { + } + + /// + /// Initializes a new instance of . + /// + /// Optional referral cache. If null, a new empty cache is created. + /// Optional domain cache. If null, a new empty cache is created. + /// Optional DFS referral transport. If null, resolution will return + /// for paths not found in cache. + public DfsPathResolver(ReferralCache referralCache, DomainCache domainCache, IDfsReferralTransport transport) + { + _referralCache = referralCache ?? new ReferralCache(); + _domainCache = domainCache ?? new DomainCache(); + _transport = transport; + } + + /// + /// Resolves a UNC path using the 14-step DFS algorithm. + /// + public DfsResolutionResult Resolve(DfsClientOptions options, string originalPath) + { + if (options == null) + { + throw new ArgumentNullException("options"); + } + + OnResolutionStarted(new DfsResolutionStartedEventArgs(originalPath)); + + if (!options.Enabled) + { + return CreateNotApplicableResult(originalPath); + } + + DfsPath dfsPath = TryParsePath(originalPath); + if (dfsPath == null || dfsPath.HasOnlyOneComponent || dfsPath.IsIpc) + { + return CreateNotApplicableResult(originalPath); + } + + DfsResolutionResult cachedResult = TryResolveFromCache(originalPath, dfsPath); + if (cachedResult != null) + { + return cachedResult; + } + + if (_transport == null) + { + return CreateNotApplicableResult(originalPath); + } + + return ResolveViaTransport(originalPath, dfsPath); + } + + private DfsPath TryParsePath(string originalPath) + { + try + { + return new DfsPath(originalPath); + } + catch (ArgumentException) + { + return null; + } + } + + private DfsResolutionResult TryResolveFromCache(string originalPath, DfsPath dfsPath) + { + ReferralCacheEntry cachedEntry = _referralCache.Lookup(originalPath); + if (cachedEntry == null) + { + return null; + } + + string resolvedPath = ResolveFromCacheEntry(originalPath, dfsPath, cachedEntry); + if (resolvedPath == null) + { + return null; + } + + return CreateSuccessResult(originalPath, resolvedPath); + } + + private DfsResolutionResult ResolveViaTransport(string originalPath, DfsPath dfsPath) + { + string serverName = dfsPath.ServerName; + OnReferralRequested(new DfsReferralRequestedEventArgs(originalPath, DfsRequestType.RootReferral, serverName)); + + byte[] buffer; + uint outputCount; + NTStatus status = _transport.TryGetReferrals(serverName, originalPath, 0, out buffer, out outputCount); + + if (status == NTStatus.STATUS_FS_DRIVER_REQUIRED) + { + return CreateNotApplicableResult(originalPath, status); + } + + if (status == NTStatus.STATUS_SUCCESS && buffer != null && buffer.Length > 0) + { + DfsResolutionResult result = TryParseReferralResponse(originalPath, buffer, outputCount, status); + if (result != null) + { + return result; + } + } + + return CreateErrorResult(originalPath, status); + } + + private DfsResolutionResult TryParseReferralResponse(string originalPath, byte[] buffer, uint outputCount, NTStatus status) + { + try + { + byte[] effectiveBuffer = GetEffectiveBuffer(buffer, outputCount); + ResponseGetDfsReferral response = new ResponseGetDfsReferral(effectiveBuffer); + + int referralCount = response.ReferralEntries != null ? response.ReferralEntries.Count : 0; + int ttlSeconds = GetTtlFromResponse(response); + + OnReferralReceived(new DfsReferralReceivedEventArgs(originalPath, status, referralCount, ttlSeconds)); + + if (referralCount > 0) + { + string resolvedPath = DfsReferralSelector.SelectResolvedPath(originalPath, response.PathConsumed, response.ReferralEntries.ToArray()); + if (!String.IsNullOrEmpty(resolvedPath)) + { + CacheReferralResult(originalPath, response, ttlSeconds); + return CreateSuccessResult(originalPath, resolvedPath); + } + } + } + catch (ArgumentException) + { + // Malformed referral response + } + + return null; + } + + private byte[] GetEffectiveBuffer(byte[] buffer, uint outputCount) + { + if (outputCount > 0 && outputCount <= (uint)buffer.Length && outputCount != (uint)buffer.Length) + { + byte[] effectiveBuffer = new byte[outputCount]; + Array.Copy(buffer, effectiveBuffer, (int)outputCount); + return effectiveBuffer; + } + return buffer; + } + + private int GetTtlFromResponse(ResponseGetDfsReferral response) + { + if (response.ReferralEntries != null && response.ReferralEntries.Count > 0) + { + return (int)GetTimeToLive(response.ReferralEntries[0]); + } + return 0; + } + + private static uint GetTimeToLive(DfsReferralEntry entry) + { + // V3/V4 have TimeToLive + DfsReferralEntryV3 v3 = entry as DfsReferralEntryV3; + if (v3 != null) + { + return v3.TimeToLive; + } + + // V2 has TimeToLive + DfsReferralEntryV2 v2 = entry as DfsReferralEntryV2; + if (v2 != null) + { + return v2.TimeToLive; + } + + // V1 has no TimeToLive - use default + return 300; + } + + private void CacheReferralResult(string originalPath, ResponseGetDfsReferral response, int ttlSeconds) + { + if (ttlSeconds <= 0) + { + return; + } + + ReferralCacheEntry newEntry = new ReferralCacheEntry(originalPath); + newEntry.TtlSeconds = (uint)ttlSeconds; + newEntry.ExpiresUtc = DateTime.UtcNow.AddSeconds(ttlSeconds); + + string networkAddress = GetNetworkAddress(response.ReferralEntries[0]); + if (!string.IsNullOrEmpty(networkAddress)) + { + newEntry.TargetList.Add(new TargetSetEntry(networkAddress)); + } + + _referralCache.Add(newEntry); + } + + private static string GetNetworkAddress(DfsReferralEntry entry) + { + // V3/V4 have NetworkAddress + DfsReferralEntryV3 v3 = entry as DfsReferralEntryV3; + if (v3 != null) + { + return v3.NetworkAddress; + } + + // V2 has NetworkAddress + DfsReferralEntryV2 v2 = entry as DfsReferralEntryV2; + if (v2 != null) + { + return v2.NetworkAddress; + } + + // V1 uses ShareName + DfsReferralEntryV1 v1 = entry as DfsReferralEntryV1; + if (v1 != null) + { + return v1.ShareName; + } + + return null; + } + + private DfsResolutionResult CreateNotApplicableResult(string originalPath) + { + return CreateNotApplicableResult(originalPath, NTStatus.STATUS_SUCCESS); + } + + private DfsResolutionResult CreateNotApplicableResult(string originalPath, NTStatus status) + { + OnReferralReceived(new DfsReferralReceivedEventArgs(originalPath, status, 0, 0)); + DfsResolutionResult result = new DfsResolutionResult(); + result.OriginalPath = originalPath; + result.Status = DfsResolutionStatus.NotApplicable; + result.ResolvedPath = originalPath; + result.IsDfsPath = false; + OnResolutionCompleted(new DfsResolutionCompletedEventArgs(originalPath, originalPath, status, false)); + return result; + } + + private DfsResolutionResult CreateSuccessResult(string originalPath, string resolvedPath) + { + DfsResolutionResult result = new DfsResolutionResult(); + result.OriginalPath = originalPath; + result.Status = DfsResolutionStatus.Success; + result.ResolvedPath = resolvedPath; + result.IsDfsPath = true; + OnResolutionCompleted(new DfsResolutionCompletedEventArgs(originalPath, resolvedPath, NTStatus.STATUS_SUCCESS, true)); + return result; + } + + private DfsResolutionResult CreateErrorResult(string originalPath, NTStatus status) + { + OnReferralReceived(new DfsReferralReceivedEventArgs(originalPath, status, 0, 0)); + DfsResolutionResult result = new DfsResolutionResult(); + result.OriginalPath = originalPath; + result.Status = DfsResolutionStatus.Error; + result.ResolvedPath = originalPath; + result.IsDfsPath = false; + OnResolutionCompleted(new DfsResolutionCompletedEventArgs(originalPath, originalPath, status, false)); + return result; + } + + /// + /// Resolves a path using a cached referral entry. + /// + private string ResolveFromCacheEntry(string originalPath, DfsPath dfsPath, ReferralCacheEntry entry) + { + TargetSetEntry target = entry.GetTargetHint(); + if (target == null) + { + return null; + } + + // Replace the DFS prefix with the target path + try + { + DfsPath targetDfsPath = new DfsPath(target.TargetPath); + DfsPath result = dfsPath.ReplacePrefix(entry.DfsPathPrefix, targetDfsPath); + return result.ToUncPath(); + } + catch (ArgumentException) + { + return null; + } + } + + protected virtual void OnResolutionStarted(DfsResolutionStartedEventArgs e) + { + EventHandler handler = ResolutionStarted; + if (handler != null) + { + handler(this, e); + } + } + + protected virtual void OnReferralRequested(DfsReferralRequestedEventArgs e) + { + EventHandler handler = ReferralRequested; + if (handler != null) + { + handler(this, e); + } + } + + protected virtual void OnReferralReceived(DfsReferralReceivedEventArgs e) + { + EventHandler handler = ReferralReceived; + if (handler != null) + { + handler(this, e); + } + } + + protected virtual void OnResolutionCompleted(DfsResolutionCompletedEventArgs e) + { + EventHandler handler = ResolutionCompleted; + if (handler != null) + { + handler(this, e); + } + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsReferralSelector.cs b/SMBLibrary/Client/DFS/DfsReferralSelector.cs new file mode 100644 index 00000000..e6b18384 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsReferralSelector.cs @@ -0,0 +1,94 @@ +using System; +using SMBLibrary; +using SMBLibrary.DFS; + +namespace SMBLibrary.Client.DFS +{ + public static class DfsReferralSelector + { + public static string SelectResolvedPath(string originalPath, ushort pathConsumed, DfsReferralEntry entry) + { + if (originalPath == null) + { + throw new ArgumentNullException("originalPath"); + } + + if (entry == null) + { + throw new ArgumentNullException("entry"); + } + + // Get the network address based on entry version + string networkAddress = GetNetworkAddress(entry); + if (string.IsNullOrEmpty(networkAddress)) + { + return null; + } + + int consumedCharacters = pathConsumed / 2; + if (consumedCharacters < 0 || consumedCharacters > originalPath.Length) + { + return null; + } + + string suffix = originalPath.Substring(consumedCharacters); + return networkAddress + suffix; + } + + private static string GetNetworkAddress(DfsReferralEntry entry) + { + // V3/V4 have NetworkAddress + DfsReferralEntryV3 v3 = entry as DfsReferralEntryV3; + if (v3 != null) + { + return v3.NetworkAddress; + } + + // V2 has NetworkAddress + DfsReferralEntryV2 v2 = entry as DfsReferralEntryV2; + if (v2 != null) + { + return v2.NetworkAddress; + } + + // V1 uses ShareName + DfsReferralEntryV1 v1 = entry as DfsReferralEntryV1; + if (v1 != null) + { + return v1.ShareName; + } + + return null; + } + + public static string SelectResolvedPath(string originalPath, ushort pathConsumed, DfsReferralEntry[] entries) + { + if (originalPath == null) + { + throw new ArgumentNullException("originalPath"); + } + + if (entries == null) + { + throw new ArgumentNullException("entries"); + } + + for (int index = 0; index < entries.Length; index++) + { + DfsReferralEntry entry = entries[index]; + if (entry == null) + { + continue; + } + + string candidate = SelectResolvedPath(originalPath, pathConsumed, entry); + if (candidate != null) + { + return candidate; + } + } + + return null; + } + } +} diff --git a/SMBLibrary/Client/DFS/DfsRequestType.cs b/SMBLibrary/Client/DFS/DfsRequestType.cs new file mode 100644 index 00000000..e621b091 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsRequestType.cs @@ -0,0 +1,40 @@ +namespace SMBLibrary.Client.DFS +{ + /// + /// Identifies the type of DFS referral request per MS-DFSC section 3.1.4.2. + /// Used by the DFS path resolution algorithm to determine which referral + /// request type to issue based on path characteristics. + /// + public enum DfsRequestType + { + /// + /// Domain referral request for a path of the form \\DomainName. + /// Returns a list of DFS root targets in the domain. + /// + DomainReferral = 0, + + /// + /// Domain controller (DC) referral request. + /// Returns a list of domain controllers for a domain. + /// + DcReferral = 1, + + /// + /// Root referral request for a path of the form \\Server\Share. + /// Returns the root targets for the DFS namespace. + /// + RootReferral = 2, + + /// + /// SYSVOL or NETLOGON referral request for domain system volumes. + /// Handled specially per MS-DFSC section 3.1.4.2 steps 10-11. + /// + SysvolReferral = 3, + + /// + /// Link referral request for a DFS folder link path. + /// Returns the folder targets for the specified link. + /// + LinkReferral = 4 + } +} diff --git a/SMBLibrary/Client/DFS/DfsResolutionResult.cs b/SMBLibrary/Client/DFS/DfsResolutionResult.cs new file mode 100644 index 00000000..ae6c766c --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsResolutionResult.cs @@ -0,0 +1,26 @@ +namespace SMBLibrary.Client.DFS +{ + /// + /// Result of a DFS client resolution attempt. + /// + public class DfsResolutionResult + { + /// + /// Overall status of the DFS resolution attempt. + /// + public DfsResolutionStatus Status { get; set; } + + /// + /// The resolved UNC path when DFS resolution succeeds, or the original path + /// when DFS is not applicable. + /// + public string ResolvedPath { get; set; } + + public string OriginalPath { get; set; } + + /// + /// Indicates whether the path was determined to be a DFS path. + /// + public bool IsDfsPath { get; set; } + } +} diff --git a/SMBLibrary/Client/DFS/DfsResolutionStatus.cs b/SMBLibrary/Client/DFS/DfsResolutionStatus.cs new file mode 100644 index 00000000..c643f294 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsResolutionStatus.cs @@ -0,0 +1,23 @@ +namespace SMBLibrary.Client.DFS +{ + /// + /// Status of a DFS client resolution attempt. + /// + public enum DfsResolutionStatus + { + /// + /// DFS resolution does not apply (for example, DFS is disabled for this connection or path). + /// + NotApplicable = 0, + + /// + /// DFS resolution succeeded and a target path is available. + /// + Success = 1, + + /// + /// DFS resolution was attempted but failed (e.g., malformed referrals, network errors). + /// + Error = 2, + } +} diff --git a/SMBLibrary/Client/DFS/DfsResolverState.cs b/SMBLibrary/Client/DFS/DfsResolverState.cs new file mode 100644 index 00000000..796c1439 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsResolverState.cs @@ -0,0 +1,69 @@ +namespace SMBLibrary.Client.DFS +{ + /// + /// Tracks state through the 14-step DFS resolution algorithm per MS-DFSC section 3.1.4.2. + /// Generic parameter T represents the connection/session context type. + /// + /// The type of context object (e.g., session, connection). + /// + /// This class is infrastructure for future state machine improvements to the DFS resolver. + /// It can be used to track resolution progress across multiple steps and enable + /// more sophisticated retry/failover logic. + /// + public class DfsResolverState + { + /// + /// Initializes a new instance of . + /// + /// The original UNC path to resolve. + /// The connection/session context. + public DfsResolverState(string originalPath, T context) + { + OriginalPath = originalPath; + CurrentPath = originalPath; + Context = context; + LastStatus = NTStatus.STATUS_SUCCESS; + } + + /// + /// Gets the original UNC path that was requested. + /// + public string OriginalPath { get; private set; } + + /// + /// Gets or sets the current path being resolved (may be rewritten during resolution). + /// + public string CurrentPath { get; set; } + + /// + /// Gets the connection/session context. + /// + public T Context { get; private set; } + + /// + /// Gets or sets the type of DFS referral request to issue, if any. + /// Null when no request type has been determined. + /// + public DfsRequestType? RequestType { get; set; } + + /// + /// Gets or sets whether resolution is complete (algorithm reached terminal state). + /// + public bool IsComplete { get; set; } + + /// + /// Gets or sets whether the path was determined to be a DFS path. + /// + public bool IsDfsPath { get; set; } + + /// + /// Gets or sets the cached referral entry found during lookup, if any. + /// + public ReferralCacheEntry CachedEntry { get; set; } + + /// + /// Gets or sets the last NTStatus from a referral request or I/O operation. + /// + public NTStatus LastStatus { get; set; } + } +} diff --git a/SMBLibrary/Client/DFS/DfsSessionManager.cs b/SMBLibrary/Client/DFS/DfsSessionManager.cs new file mode 100644 index 00000000..0ae0b244 --- /dev/null +++ b/SMBLibrary/Client/DFS/DfsSessionManager.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Delegate for creating ISMBClient instances for a given server name. + /// + /// The server name to create a client for. + /// An ISMBClient instance. + public delegate ISMBClient SmbClientFactory(string serverName); + + /// + /// Manages SMB sessions across multiple servers for DFS interlink scenarios. + /// When a DFS referral points to a different server, this manager creates + /// or reuses existing connections to that target server. + /// + public class DfsSessionManager : IDisposable + { + private readonly SmbClientFactory _clientFactory; + private readonly Dictionary _clientsByServer; + private readonly object _lock = new object(); + private bool _disposed; + + /// + /// Initializes a new instance of using the default SMB2Client factory. + /// + public DfsSessionManager() + : this(null) + { + } + + /// + /// Initializes a new instance of with a custom client factory. + /// + /// Factory function that creates an ISMBClient for a given server name. + /// If null, a default factory creating SMB2Client instances is used. + public DfsSessionManager(SmbClientFactory clientFactory) + { + _clientFactory = clientFactory ?? DefaultClientFactory; + _clientsByServer = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets or creates an ISMBFileStore for the specified server and share. + /// Reuses existing connections to the same server. + /// + /// The target server name. + /// The share name to connect to. + /// Credentials for authentication. + /// The result status of the operation. + /// An ISMBFileStore for the target share, or null if connection/login failed. + public ISMBFileStore GetOrCreateSession(string serverName, string shareName, DfsCredentials credentials, out NTStatus status) + { + if (serverName == null) + { + throw new ArgumentNullException("serverName"); + } + + if (shareName == null) + { + throw new ArgumentNullException("shareName"); + } + + if (credentials == null) + { + throw new ArgumentNullException("credentials"); + } + + lock (_lock) + { + if (_disposed) + { + throw new ObjectDisposedException("DfsSessionManager"); + } + + ISMBClient client; + if (!_clientsByServer.TryGetValue(serverName, out client)) + { + // Create new client + client = _clientFactory(serverName); + if (!client.Connect(serverName, SMBTransportType.DirectTCPTransport)) + { + status = NTStatus.STATUS_BAD_NETWORK_NAME; + return null; + } + + NTStatus loginStatus = client.Login(credentials.DomainName, credentials.UserName, credentials.Password); + if (loginStatus != NTStatus.STATUS_SUCCESS) + { + client.Disconnect(); + status = loginStatus; + return null; + } + + _clientsByServer[serverName] = client; + } + + // TreeConnect to the share + NTStatus treeConnectStatus; + ISMBFileStore store = client.TreeConnect(shareName, out treeConnectStatus); + status = treeConnectStatus; + return store; + } + } + + /// + /// Disposes all managed client connections. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + lock (_lock) + { + foreach (ISMBClient client in _clientsByServer.Values) + { + try + { + client.Disconnect(); + } + catch + { + // Ignore disconnect errors during dispose + } + } + + _clientsByServer.Clear(); + _disposed = true; + } + } + } + + private static ISMBClient DefaultClientFactory(string serverName) + { + return new SMB2Client(); + } + } +} diff --git a/SMBLibrary/Client/DFS/DomainCache.cs b/SMBLibrary/Client/DFS/DomainCache.cs new file mode 100644 index 00000000..d6f202f5 --- /dev/null +++ b/SMBLibrary/Client/DFS/DomainCache.cs @@ -0,0 +1,127 @@ +/* Copyright (C) 2014-2024 Tal Aloni . All rights reserved. + * + * You can redistribute this program and/or modify it under the terms of + * the GNU Lesser Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + */ +using System; +using System.Collections.Generic; + +namespace SMBLibrary +{ + /// + /// Domain cache for DFS domain referrals per MS-DFSC 3.1.5.1. + /// Stores DomainCacheEntry instances keyed by domain name. + /// Thread-safe via locking. + /// + public class DomainCache + { + private readonly Dictionary m_cache; + private readonly object m_lock = new object(); + + public DomainCache() + { + m_cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Adds or updates a domain cache entry. + /// + public void Add(DomainCacheEntry entry) + { + if (entry == null) + { + throw new ArgumentNullException("entry"); + } + + lock (m_lock) + { + m_cache[entry.DomainName] = entry; + } + } + + /// + /// Looks up a domain entry by domain name. + /// Returns null if not found or expired. + /// + public DomainCacheEntry Lookup(string domainName) + { + if (string.IsNullOrEmpty(domainName)) + { + return null; + } + + lock (m_lock) + { + DomainCacheEntry entry; + if (m_cache.TryGetValue(domainName, out entry)) + { + if (!entry.IsExpired) + { + return entry; + } + } + + return null; + } + } + + /// + /// Removes an entry by domain name. + /// Returns true if an entry was removed. + /// + public bool Remove(string domainName) + { + lock (m_lock) + { + return m_cache.Remove(domainName); + } + } + + // Clears only expired entries from the cache. + public void ClearExpired() + { + lock (m_lock) + { + List expired = new List(); + foreach (KeyValuePair kvp in m_cache) + { + if (kvp.Value.IsExpired) + { + expired.Add(kvp.Key); + } + } + + foreach (string key in expired) + { + m_cache.Remove(key); + } + } + } + + /// + /// Clears all entries. + /// + public void Clear() + { + lock (m_lock) + { + m_cache.Clear(); + } + } + + /// + /// Number of entries in the cache. + /// + public int Count + { + get + { + lock (m_lock) + { + return m_cache.Count; + } + } + } + } +} diff --git a/SMBLibrary/Client/DFS/DomainCacheEntry.cs b/SMBLibrary/Client/DFS/DomainCacheEntry.cs new file mode 100644 index 00000000..c0abfb18 --- /dev/null +++ b/SMBLibrary/Client/DFS/DomainCacheEntry.cs @@ -0,0 +1,114 @@ +/* Copyright (C) 2014-2024 Tal Aloni . All rights reserved. + * + * You can redistribute this program and/or modify it under the terms of + * the GNU Lesser Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + */ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace SMBLibrary +{ + /// + /// Represents a cached domain entry for DFS domain referrals per MS-DFSC 3.1.5.1. + /// Contains the domain name and list of domain controllers. + /// + public class DomainCacheEntry + { + private string m_domainName; + private List m_dcList; + private DateTime m_expiresUtc; + private int m_dcHintIndex; + + public DomainCacheEntry(string domainName) + { + m_domainName = domainName; + m_dcList = new List(); + m_expiresUtc = DateTime.MinValue; + m_dcHintIndex = 0; + } + + /// + /// The domain name (e.g., "contoso.com"). + /// + public string DomainName + { + get { return m_domainName; } + set { m_domainName = value; } + } + + /// + /// List of domain controllers for this domain. + /// + public List DcList + { + get { return m_dcList; } + } + + /// + /// Absolute UTC time when this entry expires. + /// + public DateTime ExpiresUtc + { + get { return m_expiresUtc; } + set { m_expiresUtc = value; } + } + + /// + /// True if this entry has expired. + /// + public bool IsExpired + { + get { return DateTime.UtcNow >= m_expiresUtc; } + } + + /// + /// Gets the current DC hint (the DC to try next). + /// Returns null if no DCs are available. + /// + public string GetDcHint() + { + if (m_dcList.Count == 0) + { + return null; + } + + int index = m_dcHintIndex; + if (index >= m_dcList.Count) + { + index = 0; + } + + return m_dcList[index]; + } + + /// + /// Advances to the next DC in the list (round-robin failover). + /// + public void NextDcHint() + { + if (m_dcList.Count == 0) + { + return; + } + + int currentIndex; + int newIndex; + do + { + currentIndex = m_dcHintIndex; + newIndex = (currentIndex + 1) % m_dcList.Count; + } + while (Interlocked.CompareExchange(ref m_dcHintIndex, newIndex, currentIndex) != currentIndex); + } + + /// + /// Resets the DC hint to the first DC. + /// + public void ResetDcHint() + { + Interlocked.Exchange(ref m_dcHintIndex, 0); + } + } +} diff --git a/SMBLibrary/Client/DFS/IDfsClientResolver.cs b/SMBLibrary/Client/DFS/IDfsClientResolver.cs new file mode 100644 index 00000000..c2d48e11 --- /dev/null +++ b/SMBLibrary/Client/DFS/IDfsClientResolver.cs @@ -0,0 +1,18 @@ +namespace SMBLibrary.Client.DFS +{ + /// + /// Abstraction for resolving DFS paths on the client side. + /// + public interface IDfsClientResolver + { + /// + /// Resolve a UNC path according to DFS settings. + /// For vNext-DFS, if DFS is disabled for the provided options, implementations + /// should return a result with Status=NotApplicable and the original path. + /// + /// DFS client options for this resolution attempt. + /// The UNC path requested by the caller. + /// A DFS resolution result describing the outcome. + DfsResolutionResult Resolve(DfsClientOptions options, string originalPath); + } +} diff --git a/SMBLibrary/Client/DFS/IDfsReferralTransport.cs b/SMBLibrary/Client/DFS/IDfsReferralTransport.cs new file mode 100644 index 00000000..8af091a6 --- /dev/null +++ b/SMBLibrary/Client/DFS/IDfsReferralTransport.cs @@ -0,0 +1,9 @@ +using SMBLibrary; + +namespace SMBLibrary.Client.DFS +{ + public interface IDfsReferralTransport + { + NTStatus TryGetReferrals(string serverName, string dfsPath, uint maxOutputSize, out byte[] buffer, out uint outputCount); + } +} diff --git a/SMBLibrary/Client/DFS/ReferralCache.cs b/SMBLibrary/Client/DFS/ReferralCache.cs new file mode 100644 index 00000000..485a401c --- /dev/null +++ b/SMBLibrary/Client/DFS/ReferralCache.cs @@ -0,0 +1,181 @@ +/* Copyright (C) 2014-2024 Tal Aloni . All rights reserved. + * + * You can redistribute this program and/or modify it under the terms of + * the GNU Lesser Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + */ +using System; +using System.Collections.Generic; + +namespace SMBLibrary +{ + /// + /// Referral cache for DFS path resolution per MS-DFSC 3.1.5.1. + /// Stores referral entries keyed by DFS path prefix for O(1) exact lookups. + /// Supports longest-prefix matching for path resolution. + /// Thread-safe via locking. + /// + public class ReferralCache + { + private readonly Dictionary m_cache; + private readonly object m_lock = new object(); + + public ReferralCache() + { + m_cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Adds or updates a referral cache entry. + /// + public void Add(ReferralCacheEntry entry) + { + if (entry == null) + { + throw new ArgumentNullException("entry"); + } + + lock (m_lock) + { + m_cache[entry.DfsPathPrefix] = entry; + } + } + + /// + /// Looks up a referral entry by path. Returns the longest matching prefix. + /// Returns null if no match found or if the entry is expired. + /// + public ReferralCacheEntry Lookup(string path) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + lock (m_lock) + { + // Try exact match first + ReferralCacheEntry exactMatch; + if (m_cache.TryGetValue(path, out exactMatch)) + { + if (!exactMatch.IsExpired) + { + return exactMatch; + } + } + + // Find longest prefix match + ReferralCacheEntry bestMatch = null; + int bestMatchLength = 0; + + foreach (KeyValuePair kvp in m_cache) + { + if (kvp.Value.IsExpired) + { + continue; + } + + string prefix = kvp.Key; + if (IsPathPrefix(prefix, path) && prefix.Length > bestMatchLength) + { + bestMatch = kvp.Value; + bestMatchLength = prefix.Length; + } + } + + return bestMatch; + } + } + + /// + /// Removes a referral entry by path prefix. + /// Returns true if an entry was removed. + /// + public bool Remove(string pathPrefix) + { + lock (m_lock) + { + return m_cache.Remove(pathPrefix); + } + } + + /// + /// Clears all expired entries from the cache. + /// + public void ClearExpired() + { + lock (m_lock) + { + List expiredKeys = new List(); + foreach (KeyValuePair kvp in m_cache) + { + if (kvp.Value.IsExpired) + { + expiredKeys.Add(kvp.Key); + } + } + + foreach (string key in expiredKeys) + { + m_cache.Remove(key); + } + } + } + + /// + /// Clears all entries from the cache. + /// + public void Clear() + { + lock (m_lock) + { + m_cache.Clear(); + } + } + + /// + /// Gets the number of entries in the cache. + /// + public int Count + { + get + { + lock (m_lock) + { + return m_cache.Count; + } + } + } + + /// + /// Checks if prefix is a path prefix of fullPath (case-insensitive). + /// + private static bool IsPathPrefix(string prefix, string fullPath) + { + if (string.IsNullOrEmpty(prefix) || string.IsNullOrEmpty(fullPath)) + { + return false; + } + + if (fullPath.Length < prefix.Length) + { + return false; + } + + // Case-insensitive prefix check + if (!fullPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Ensure it's a path boundary (exact match or followed by backslash) + if (fullPath.Length == prefix.Length) + { + return true; + } + + char nextChar = fullPath[prefix.Length]; + return nextChar == '\\' || nextChar == '/'; + } + } +} diff --git a/SMBLibrary/Client/DFS/ReferralCacheEntry.cs b/SMBLibrary/Client/DFS/ReferralCacheEntry.cs new file mode 100644 index 00000000..97b3bc2b --- /dev/null +++ b/SMBLibrary/Client/DFS/ReferralCacheEntry.cs @@ -0,0 +1,175 @@ +/* Copyright (C) 2014-2024 Tal Aloni . All rights reserved. + * + * You can redistribute this program and/or modify it under the terms of + * the GNU Lesser Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + */ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace SMBLibrary +{ + /// + /// Represents a cached DFS referral entry per MS-DFSC 3.1.5.1. + /// Contains the DFS path prefix, targets, TTL, and failover state. + /// + public class ReferralCacheEntry + { + private string m_dfsPathPrefix; + private ReferralCacheEntryType m_rootOrLink; + private bool m_isInterlink; + private uint m_ttlSeconds; + private DateTime m_expiresUtc; + private bool m_targetFailback; + private List m_targetList; + private int m_targetHintIndex; + + public ReferralCacheEntry(string dfsPathPrefix) + { + m_dfsPathPrefix = dfsPathPrefix; + m_rootOrLink = ReferralCacheEntryType.Root; + m_isInterlink = false; + m_ttlSeconds = 0; + m_expiresUtc = DateTime.MinValue; + m_targetFailback = false; + m_targetList = new List(); + m_targetHintIndex = 0; + } + + /// + /// The DFS path prefix that this entry matches. + /// + public string DfsPathPrefix + { + get { return m_dfsPathPrefix; } + set { m_dfsPathPrefix = value; } + } + + /// + /// Indicates whether this is a root or link referral. + /// + public ReferralCacheEntryType RootOrLink + { + get { return m_rootOrLink; } + set { m_rootOrLink = value; } + } + + /// + /// True if this is a DFS root entry. + /// + public bool IsRoot + { + get { return m_rootOrLink == ReferralCacheEntryType.Root; } + } + + /// + /// True if this is a DFS link entry. + /// + public bool IsLink + { + get { return m_rootOrLink == ReferralCacheEntryType.Link; } + } + + /// + /// True if this is an interlink (link that points to another DFS namespace). + /// + public bool IsInterlink + { + get { return m_isInterlink; } + set { m_isInterlink = value; } + } + + /// + /// Time-to-live in seconds from the referral response. + /// + public uint TtlSeconds + { + get { return m_ttlSeconds; } + set { m_ttlSeconds = value; } + } + + /// + /// Absolute UTC time when this entry expires. + /// + public DateTime ExpiresUtc + { + get { return m_expiresUtc; } + set { m_expiresUtc = value; } + } + + /// + /// True if this entry has expired and should be refreshed. + /// + public bool IsExpired + { + get { return DateTime.UtcNow >= m_expiresUtc; } + } + + /// + /// True if the server supports target failback (returning to higher priority targets). + /// + public bool TargetFailback + { + get { return m_targetFailback; } + set { m_targetFailback = value; } + } + + /// + /// List of targets for this referral, in priority order. + /// + public List TargetList + { + get { return m_targetList; } + } + + /// + /// Gets the current target hint (the target to try next). + /// Returns null if no targets are available. + /// + public TargetSetEntry GetTargetHint() + { + if (m_targetList.Count == 0) + { + return null; + } + + int index = m_targetHintIndex; + if (index >= m_targetList.Count) + { + index = 0; + } + + return m_targetList[index]; + } + + /// + /// Advances to the next target in the list (round-robin failover). + /// Thread-safe via Interlocked.CompareExchange. + /// + public void NextTargetHint() + { + if (m_targetList.Count == 0) + { + return; + } + + int currentIndex; + int newIndex; + do + { + currentIndex = m_targetHintIndex; + newIndex = (currentIndex + 1) % m_targetList.Count; + } + while (Interlocked.CompareExchange(ref m_targetHintIndex, newIndex, currentIndex) != currentIndex); + } + + /// + /// Resets the target hint to the first (highest priority) target. + /// + public void ResetTargetHint() + { + Interlocked.Exchange(ref m_targetHintIndex, 0); + } + } +} diff --git a/SMBLibrary/Client/DFS/ReferralCacheEntryType.cs b/SMBLibrary/Client/DFS/ReferralCacheEntryType.cs new file mode 100644 index 00000000..8b1c811b --- /dev/null +++ b/SMBLibrary/Client/DFS/ReferralCacheEntryType.cs @@ -0,0 +1,26 @@ +/* Copyright (C) 2014-2024 Tal Aloni . All rights reserved. + * + * You can redistribute this program and/or modify it under the terms of + * the GNU Lesser Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + */ + +namespace SMBLibrary +{ + /// + /// Indicates whether a referral cache entry is for a DFS root or a DFS link. + /// Per MS-DFSC 3.1.5.1 referral cache. + /// + public enum ReferralCacheEntryType + { + /// + /// The entry is for a DFS root (namespace). + /// + Root = 0, + + /// + /// The entry is for a DFS link (folder redirect). + /// + Link = 1 + } +} diff --git a/SMBLibrary/Client/DFS/ReferralCacheTree.cs b/SMBLibrary/Client/DFS/ReferralCacheTree.cs new file mode 100644 index 00000000..60d82a18 --- /dev/null +++ b/SMBLibrary/Client/DFS/ReferralCacheTree.cs @@ -0,0 +1,276 @@ +/* Copyright (C) 2014-2024 Tal Aloni . All rights reserved. + * + * You can redistribute this program and/or modify it under the terms of + * the GNU Lesser Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + */ +using System; +using System.Collections.Generic; + +namespace SMBLibrary.Client.DFS +{ + /// + /// Tree-based referral cache for DFS path resolution per MS-DFSC 3.1.5.1. + /// Provides O(k) longest-prefix-match lookups where k is the path depth. + /// Thread-safe via locking. + /// + public class ReferralCacheTree + { + private readonly ReferralCacheNode m_root; + private readonly object m_lock = new object(); + private int m_count; + + public ReferralCacheTree() + { + m_root = new ReferralCacheNode(string.Empty); + m_count = 0; + } + + /// + /// Adds or updates a referral cache entry. + /// + public void Add(ReferralCacheEntry entry) + { + if (entry == null) + { + throw new ArgumentNullException("entry"); + } + + if (string.IsNullOrEmpty(entry.DfsPathPrefix)) + { + throw new ArgumentException("DfsPathPrefix cannot be null or empty", "entry"); + } + + List components = SplitPath(entry.DfsPathPrefix); + if (components.Count == 0) + { + return; + } + + lock (m_lock) + { + ReferralCacheNode node = m_root; + foreach (string component in components) + { + node = node.GetOrCreateChild(component); + } + + bool isNew = node.Entry == null; + node.Entry = entry; + + if (isNew) + { + m_count++; + } + } + } + + /// + /// Looks up a referral entry by path. Returns the longest matching prefix. + /// Returns null if no match found or if the entry is expired. + /// + public ReferralCacheEntry Lookup(string path) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + List components = SplitPath(path); + if (components.Count == 0) + { + return null; + } + + lock (m_lock) + { + ReferralCacheEntry bestMatch = null; + ReferralCacheNode node = m_root; + + foreach (string component in components) + { + ReferralCacheNode child = node.GetChild(component); + if (child == null) + { + break; + } + + node = child; + + // Check if this node has a valid (non-expired) entry + if (node.Entry != null && !node.Entry.IsExpired) + { + bestMatch = node.Entry; + } + } + + return bestMatch; + } + } + + /// + /// Removes a referral entry by path prefix. + /// Returns true if an entry was removed. + /// + public bool Remove(string pathPrefix) + { + if (string.IsNullOrEmpty(pathPrefix)) + { + return false; + } + + List components = SplitPath(pathPrefix); + if (components.Count == 0) + { + return false; + } + + lock (m_lock) + { + ReferralCacheNode node = m_root; + foreach (string component in components) + { + ReferralCacheNode child = node.GetChild(component); + if (child == null) + { + return false; + } + node = child; + } + + if (node.Entry != null) + { + node.Entry = null; + m_count--; + return true; + } + + return false; + } + } + + /// + /// Clears all expired entries from the cache. + /// + public void ClearExpired() + { + lock (m_lock) + { + ClearExpiredRecursive(m_root); + } + } + + private void ClearExpiredRecursive(ReferralCacheNode node) + { + if (node.Entry != null && node.Entry.IsExpired) + { + node.Entry = null; + m_count--; + } + + foreach (ReferralCacheNode child in node.GetChildren()) + { + ClearExpiredRecursive(child); + } + } + + /// + /// Clears all entries from the cache. + /// + public void Clear() + { + lock (m_lock) + { + m_root.ClearChildren(); + m_count = 0; + } + } + + /// + /// Gets the number of entries in the cache. + /// + public int Count + { + get + { + lock (m_lock) + { + return m_count; + } + } + } + + /// + /// Splits a UNC path into components. + /// + private static List SplitPath(string path) + { + List components = new List(); + char[] separators = new char[] { '\\', '/' }; + string[] parts = path.Split(separators, StringSplitOptions.RemoveEmptyEntries); + + foreach (string part in parts) + { + if (part.Length > 0) + { + components.Add(part); + } + } + + return components; + } + + /// + /// Internal tree node for the referral cache. + /// + private class ReferralCacheNode + { + private readonly Dictionary m_children; + private ReferralCacheEntry m_entry; + + public ReferralCacheNode(string component) + { + // Component parameter kept for potential future use (e.g., debugging, path reconstruction) + m_children = new Dictionary(StringComparer.OrdinalIgnoreCase); + m_entry = null; + } + + public ReferralCacheEntry Entry + { + get { return m_entry; } + set { m_entry = value; } + } + + public ReferralCacheNode GetChild(string component) + { + ReferralCacheNode child; + if (m_children.TryGetValue(component, out child)) + { + return child; + } + return null; + } + + public ReferralCacheNode GetOrCreateChild(string component) + { + ReferralCacheNode child; + if (!m_children.TryGetValue(component, out child)) + { + child = new ReferralCacheNode(component); + m_children[component] = child; + } + return child; + } + + public IEnumerable GetChildren() + { + return m_children.Values; + } + + public void ClearChildren() + { + m_children.Clear(); + } + } + } +} diff --git a/SMBLibrary/Client/DFS/Smb2DfsReferralTransport.cs b/SMBLibrary/Client/DFS/Smb2DfsReferralTransport.cs new file mode 100644 index 00000000..def76309 --- /dev/null +++ b/SMBLibrary/Client/DFS/Smb2DfsReferralTransport.cs @@ -0,0 +1,62 @@ +using System; +using SMBLibrary; +using SMBLibrary.SMB2; + +namespace SMBLibrary.Client.DFS +{ + public class Smb2DfsReferralTransport : IDfsReferralTransport + { + public delegate NTStatus Smb2IoctlSender(IOCtlRequest request, out byte[] output, out uint outputCount); + + private readonly Smb2IoctlSender _sender; + + public Smb2DfsReferralTransport(Smb2IoctlSender sender) + { + if (sender == null) + { + throw new ArgumentNullException("sender"); + } + + _sender = sender; + } + + public NTStatus TryGetReferrals(string serverName, string dfsPath, uint maxOutputSize, out byte[] buffer, out uint outputCount) + { + IOCtlRequest request = DfsIoctlRequestBuilder.CreateDfsReferralRequest(dfsPath, maxOutputSize); + return _sender(request, out buffer, out outputCount); + } + + internal static IDfsReferralTransport CreateUsingDeviceIOControl(INTFileStore fileStore, object handle) + { + if (fileStore == null) + { + throw new ArgumentNullException("fileStore"); + } + + // Default to the well-known DFS referral FileID when no handle is provided. + // DFS referral IOCTLs use FileID 0xFFFFFFFFFFFFFFFF per MS-SMB2 §2.2.31. + object effectiveHandle = handle ?? (object)DfsIoctlRequestBuilder.DfsReferralFileId; + + Smb2IoctlSender sender = delegate (IOCtlRequest request, out byte[] output, out uint outputCount) + { + byte[] deviceOutput; + NTStatus status = fileStore.DeviceIOControl(effectiveHandle, request.CtlCode, request.Input, out deviceOutput, (int)request.MaxOutputResponse); + + if (deviceOutput != null) + { + output = deviceOutput; + outputCount = (uint)deviceOutput.Length; + } + else + { + output = null; + outputCount = 0; + } + + return status; + }; + + return new Smb2DfsReferralTransport(sender); + } + } +} diff --git a/SMBLibrary/Client/DFS/TargetSetEntry.cs b/SMBLibrary/Client/DFS/TargetSetEntry.cs new file mode 100644 index 00000000..9a81640a --- /dev/null +++ b/SMBLibrary/Client/DFS/TargetSetEntry.cs @@ -0,0 +1,67 @@ +/* Copyright (C) 2014-2024 Tal Aloni . All rights reserved. + * + * You can redistribute this program and/or modify it under the terms of + * the GNU Lesser Public License as published by the Free Software Foundation, + * either version 3 of the License, or (at your option) any later version. + */ +using SMBLibrary.DFS; + +namespace SMBLibrary +{ + /// + /// Represents a single target in a DFS referral response. + /// Used for target failover and load balancing per MS-DFSC 3.1.5.4.5. + /// + public class TargetSetEntry + { + private string m_targetPath; + private int m_priority; + private bool m_isTargetSetBoundary; + private DfsServerType m_serverType; + + public TargetSetEntry(string targetPath) + { + m_targetPath = targetPath; + m_priority = 0; + m_isTargetSetBoundary = false; + m_serverType = DfsServerType.NonRoot; + } + + /// + /// The UNC path to the target server/share (NetworkAddress from referral). + /// + public string TargetPath + { + get { return m_targetPath; } + set { m_targetPath = value; } + } + + /// + /// Target priority for failover ordering (lower = higher priority). + /// Derived from referral Proximity or target set position. + /// + public int Priority + { + get { return m_priority; } + set { m_priority = value; } + } + + /// + /// True if this target marks the start of a new target set (V4 referrals). + /// + public bool IsTargetSetBoundary + { + get { return m_isTargetSetBoundary; } + set { m_isTargetSetBoundary = value; } + } + + /// + /// The type of DFS server (Root or NonRoot). + /// + public DfsServerType ServerType + { + get { return m_serverType; } + set { m_serverType = value; } + } + } +} diff --git a/SMBLibrary/Client/SMB2FileStore.cs b/SMBLibrary/Client/SMB2FileStore.cs index 59a30f77..3ce3e1ab 100644 --- a/SMBLibrary/Client/SMB2FileStore.cs +++ b/SMBLibrary/Client/SMB2FileStore.cs @@ -312,14 +312,28 @@ public NTStatus Cancel(object ioRequest) throw new NotImplementedException(); } + /// + /// Send an IOCTL/FSCTL request using a file handle. + /// + /// Thrown when handle is null. Use the FileID overload for DFS referrals. public NTStatus DeviceIOControl(object handle, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) + { + return DeviceIOControl((FileID)handle, ctlCode, input, out output, maxOutputLength); + } + + /// + /// Send an IOCTL/FSCTL request with an explicit FileID. + /// Use this overload for FSCTLs that don't require an open file handle, + /// such as FSCTL_DFS_GET_REFERRALS which uses the special FileID (0xFFFFFFFFFFFFFFFF). + /// + public NTStatus DeviceIOControl(FileID fileId, uint ctlCode, byte[] input, out byte[] output, int maxOutputLength) { output = null; IOCtlRequest request = new IOCtlRequest(); request.Header.CreditCharge = (ushort)Math.Ceiling((double)maxOutputLength / BytesPerCredit); request.CtlCode = ctlCode; request.IsFSCtl = true; - request.FileId = (FileID)handle; + request.FileId = fileId; request.Input = input; request.MaxOutputResponse = (uint)maxOutputLength; TrySendCommand(request); diff --git a/SMBLibrary/SMBLibrary.csproj b/SMBLibrary/SMBLibrary.csproj index 9ae86b30..6ac7c127 100644 --- a/SMBLibrary/SMBLibrary.csproj +++ b/SMBLibrary/SMBLibrary.csproj @@ -3,6 +3,7 @@ net20;net40;netstandard2.0 SMBLibrary + 1.5.6.3 1.5.7 1573;1591 SMBLibrary