From 9bf318e41efa40b48994348c2586e399c652f2df Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Mon, 5 Jan 2026 07:16:16 +1100 Subject: [PATCH] Initial import: renamed to DevDrive --- .gitignore | 48 +++ DevDrive/DevDrive.csproj | 16 + DevDrive/DriveHider.cs | 174 ++++++++++ DevDrive/DriveIconHelper.cs | 105 ++++++ DevDrive/FloppyConfig.cs | 33 ++ DevDrive/FloppyFileNode.cs | 39 +++ DevDrive/FloppyFileSystem.cs | 631 +++++++++++++++++++++++++++++++++++ DevDrive/FloppyStorage.cs | 213 ++++++++++++ DevDrive/Program.cs | 413 +++++++++++++++++++++++ DevDrive/SymlinkHandler.cs | 201 +++++++++++ 10 files changed, 1873 insertions(+) create mode 100644 .gitignore create mode 100644 DevDrive/DevDrive.csproj create mode 100644 DevDrive/DriveHider.cs create mode 100644 DevDrive/DriveIconHelper.cs create mode 100644 DevDrive/FloppyConfig.cs create mode 100644 DevDrive/FloppyFileNode.cs create mode 100644 DevDrive/FloppyFileSystem.cs create mode 100644 DevDrive/FloppyStorage.cs create mode 100644 DevDrive/Program.cs create mode 100644 DevDrive/SymlinkHandler.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9311a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# .NET / Visual Studio build outputs +.vs/ +[Bb]in/ +[Oo]bj/ + +# VS Code workspace settings +.vscode/ + +# NuGet packages and artifacts +*.nupkg +packages/ + +# Test results and coverage +TestResult*/ +*.TestResult.xml +coverage/ +*.coverage +*.coveragexml + +# User-specific and IDE files +*.user +*.suo +*.userprefs +.idea/ +*.sln.iml +_ReSharper*/ +*.[Rr]e[Ss]harper + +# Logs and caches +*.log +*.svclog +*.pidb +*.psess +*.vsp +*.vspx + +# Debug symbols +*.pdb + +# OS-specific clutter +Thumbs.db +ehthumbs.db +Desktop.ini +.DS_Store + +# Publish outputs +publish/ +**/PublishOutput/ diff --git a/DevDrive/DevDrive.csproj b/DevDrive/DevDrive.csproj new file mode 100644 index 0000000..2f499c5 --- /dev/null +++ b/DevDrive/DevDrive.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0-windows + enable + enable + x64 + + + + + + + + diff --git a/DevDrive/DriveHider.cs b/DevDrive/DriveHider.cs new file mode 100644 index 0000000..3efb7ed --- /dev/null +++ b/DevDrive/DriveHider.cs @@ -0,0 +1,174 @@ +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace DevDrive; + +/// +/// Handles hiding and showing drives from Windows Explorer using registry. +/// +[SupportedOSPlatform("windows")] +public static class DriveHider +{ + private const string LogPrefix = "[DriveHider]"; + private const string ExplorerPoliciesKey = @"Software\Microsoft\Windows\CurrentVersion\Policies\Explorer"; + private const string NoDrivesValue = "NoDrives"; + + private static int _hiddenDriveMask; + private static string? _hiddenDriveLetter; + private static int _originalNoDrives; + + public static bool HideDrive(string driveLetter) + { + if (_hiddenDriveLetter != null) + { + Console.WriteLine($"{LogPrefix} Drive already hidden"); + return true; + } + + try + { + var letter = driveLetter.TrimEnd('\\', ':').ToUpperInvariant(); + if (letter.Length != 1 || letter[0] < 'A' || letter[0] > 'Z') + { + Console.WriteLine($"{LogPrefix} Invalid drive letter: {driveLetter}"); + return false; + } + + var driveIndex = letter[0] - 'A'; + _hiddenDriveMask = 1 << driveIndex; + _hiddenDriveLetter = letter + ":\\"; + + using var key = Registry.CurrentUser.CreateSubKey(ExplorerPoliciesKey); + if (key == null) + { + Console.WriteLine($"{LogPrefix} Failed to open registry key"); + return false; + } + + _originalNoDrives = GetNoDrivesValue(key); + var newValue = _originalNoDrives | _hiddenDriveMask; + + key.SetValue(NoDrivesValue, newValue, RegistryValueKind.DWord); + + RefreshExplorer(); + + Console.WriteLine($"{LogPrefix} Hidden drive {letter}: from Explorer"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Error hiding drive: {ex.Message}"); + _hiddenDriveMask = 0; + _hiddenDriveLetter = null; + return false; + } + } + + public static bool ShowDrive() + { + if (_hiddenDriveLetter == null) + { + return true; + } + + try + { + using var key = Registry.CurrentUser.OpenSubKey(ExplorerPoliciesKey, writable: true); + if (key == null) + { + Console.WriteLine($"{LogPrefix} Failed to open registry key for restore"); + return false; + } + + var currentValue = GetNoDrivesValue(key); + var newValue = currentValue & ~_hiddenDriveMask; + + if (newValue == 0) + { + key.DeleteValue(NoDrivesValue, throwOnMissingValue: false); + } + else + { + key.SetValue(NoDrivesValue, newValue, RegistryValueKind.DWord); + } + + RefreshExplorer(); + + Console.WriteLine($"{LogPrefix} Restored drive {_hiddenDriveLetter.TrimEnd('\\')} in Explorer"); + _hiddenDriveMask = 0; + _hiddenDriveLetter = null; + return true; + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Error restoring drive: {ex.Message}"); + return false; + } + } + + public static bool IsHidden => _hiddenDriveLetter != null; + public static string? HiddenDriveLetter => _hiddenDriveLetter; + + private static int GetNoDrivesValue(RegistryKey key) + { + var value = key.GetValue(NoDrivesValue); + + return value switch + { + null => 0, + int intValue => intValue, + uint uintValue => (int)uintValue, + byte[] bytes when bytes.Length >= 4 => BitConverter.ToInt32(bytes, 0), + byte[] bytes when bytes.Length > 0 => bytes[0], + _ => 0 + }; + } + + private static void RefreshExplorer() + { + try + { + NativeMethods.SendMessageTimeout( + NativeMethods.HWND_BROADCAST, + NativeMethods.WM_SETTINGCHANGE, + IntPtr.Zero, + "Policy", + NativeMethods.SMTO_ABORTIFHUNG, + 1000, + out _); + + NativeMethods.SHChangeNotify( + NativeMethods.SHCNE_DRIVEADD | NativeMethods.SHCNE_DRIVEREMOVED, + NativeMethods.SHCNF_FLUSHNOWAIT, + IntPtr.Zero, + IntPtr.Zero); + } + catch + { + } + } + + private static class NativeMethods + { + public const int SHCNE_DRIVEADD = 0x00000100; + public const int SHCNE_DRIVEREMOVED = 0x00000080; + public const int SHCNF_FLUSHNOWAIT = 0x3000; + + public static readonly IntPtr HWND_BROADCAST = new(0xFFFF); + public const int WM_SETTINGCHANGE = 0x001A; + public const int SMTO_ABORTIFHUNG = 0x0002; + + [System.Runtime.InteropServices.DllImport("shell32.dll")] + public static extern void SHChangeNotify(int wEventId, int uFlags, IntPtr dwItem1, IntPtr dwItem2); + + [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, + int Msg, + IntPtr wParam, + string lParam, + int fuFlags, + int uTimeout, + out IntPtr lpdwResult); + } +} diff --git a/DevDrive/DriveIconHelper.cs b/DevDrive/DriveIconHelper.cs new file mode 100644 index 0000000..9f2ef9c --- /dev/null +++ b/DevDrive/DriveIconHelper.cs @@ -0,0 +1,105 @@ +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace DevDrive; + +/// +/// Sets custom drive icons via the Windows registry. +/// +[SupportedOSPlatform("windows")] +public static class DriveIconHelper +{ + private const string LogPrefix = "[DriveIcon]"; + private const string DriveIconsKey = @"Software\Classes\Applications\Explorer.exe\Drives"; + + private static string? _currentDriveLetter; + + public static bool SetDriveIcon(string driveLetter, string iconPath, string? driveLabel = null) + { + try + { + var letter = driveLetter.TrimEnd('\\', ':').ToUpperInvariant(); + if (letter.Length != 1 || letter[0] < 'A' || letter[0] > 'Z') + { + Console.WriteLine($"{LogPrefix} Invalid drive letter: {driveLetter}"); + return false; + } + + var driveKeyPath = @$"{DriveIconsKey}\{letter}\DefaultIcon"; + + using var key = Registry.CurrentUser.CreateSubKey(driveKeyPath); + if (key == null) + { + Console.WriteLine($"{LogPrefix} Failed to create registry key"); + return false; + } + + key.SetValue("", iconPath); + _currentDriveLetter = letter; + + if (!string.IsNullOrEmpty(driveLabel)) + { + var labelKeyPath = @$"{DriveIconsKey}\{letter}\DefaultLabel"; + using var labelKey = Registry.CurrentUser.CreateSubKey(labelKeyPath); + labelKey?.SetValue("", driveLabel); + } + + RefreshShell(); + + Console.WriteLine($"{LogPrefix} Set icon for drive {letter}: to {iconPath}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Error setting icon: {ex.Message}"); + return false; + } + } + + public static void RemoveDriveIcon() + { + if (_currentDriveLetter == null) + { + return; + } + + try + { + var driveKeyPath = @$"{DriveIconsKey}\{_currentDriveLetter}"; + Registry.CurrentUser.DeleteSubKeyTree(driveKeyPath, throwOnMissingSubKey: false); + + Console.WriteLine($"{LogPrefix} Removed icon for drive {_currentDriveLetter}:"); + _currentDriveLetter = null; + + RefreshShell(); + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Error removing icon: {ex.Message}"); + } + } + + private static void RefreshShell() + { + try + { + NativeMethods.SHChangeNotify( + NativeMethods.SHCNE_ASSOCCHANGED, + NativeMethods.SHCNF_IDLIST, + IntPtr.Zero, + IntPtr.Zero); + } + catch + { + } + } + + private static class NativeMethods + { + public const int SHCNE_ASSOCCHANGED = 0x08000000; + public const int SHCNF_IDLIST = 0x0000; + + [System.Runtime.InteropServices.DllImport("shell32.dll")] + public static extern void SHChangeNotify(int wEventId, int uFlags, IntPtr dwItem1, IntPtr dwItem2); + } +} diff --git a/DevDrive/FloppyConfig.cs b/DevDrive/FloppyConfig.cs new file mode 100644 index 0000000..71ca47f --- /dev/null +++ b/DevDrive/FloppyConfig.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace DevDrive; + +/// +/// Represents the index.json configuration file structure. +/// +public class FloppyConfig +{ + [JsonPropertyName("driveName")] + public string DriveName { get; set; } = "Dev Disk"; + + [JsonPropertyName("driveIcon")] + public string DriveIcon { get; set; } = ""; + + [JsonPropertyName("driveTotalMB")] + public double DriveTotalMB { get; set; } = 100; + + [JsonPropertyName("driveFreeMB")] + public double DriveFreeMB { get; set; } = 50; + + [JsonPropertyName("paths")] + public List Paths { get; set; } = []; +} + +public class SymlinkEntry +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; +} diff --git a/DevDrive/FloppyFileNode.cs b/DevDrive/FloppyFileNode.cs new file mode 100644 index 0000000..b33c510 --- /dev/null +++ b/DevDrive/FloppyFileNode.cs @@ -0,0 +1,39 @@ +using System.Security.AccessControl; + +namespace DevDrive; + +/// +/// Represents a file or directory node in the virtual file system. +/// +public class FloppyFileNode +{ + public string Name { get; set; } + public string FullPath { get; set; } + public bool IsDirectory { get; set; } + public FileAttributes Attributes { get; set; } + public byte[] Data { get; set; } + public DateTime CreationTime { get; set; } + public DateTime LastAccessTime { get; set; } + public DateTime LastWriteTime { get; set; } + public FileSystemSecurity? Security { get; set; } + public string? SymlinkTarget { get; set; } + public bool IsSymlink => !string.IsNullOrEmpty(SymlinkTarget); + + public FloppyFileNode(string fullPath, bool isDirectory) + { + FullPath = fullPath.TrimEnd('\\'); + Name = Path.GetFileName(fullPath); + if (string.IsNullOrEmpty(Name)) + { + Name = "\\"; + } + IsDirectory = isDirectory; + Attributes = isDirectory ? FileAttributes.Directory : FileAttributes.Normal; + Data = []; + CreationTime = DateTime.Now; + LastAccessTime = DateTime.Now; + LastWriteTime = DateTime.Now; + } + + public long FileSize => IsDirectory ? 0 : Data.Length; +} diff --git a/DevDrive/FloppyFileSystem.cs b/DevDrive/FloppyFileSystem.cs new file mode 100644 index 0000000..088357d --- /dev/null +++ b/DevDrive/FloppyFileSystem.cs @@ -0,0 +1,631 @@ +using System.Security.AccessControl; +using DokanNet; +using FileAccess = DokanNet.FileAccess; + +namespace DevDrive; + +public class FloppyFileSystem : IDokanOperations +{ + private const string DefaultVolumeName = "Dev Disk"; + private const string FileSystemName = "FAT"; + private const uint VolumeSerial = 0x19950101; + + private readonly FloppyStorage _storage; + private readonly string _volumeName; + + public FloppyFileSystem(string? volumeName = null, long totalBytes = FloppyStorage.DefaultCapacity, long freeBytes = FloppyStorage.DefaultCapacity) + { + _storage = new FloppyStorage(totalBytes, freeBytes); + _volumeName = volumeName ?? DefaultVolumeName; + } + + public FloppyFileSystem(Dictionary? symlinks, string? volumeName, long totalBytes, long freeBytes) + { + _storage = new FloppyStorage(totalBytes, freeBytes); + _volumeName = volumeName ?? DefaultVolumeName; + + if (symlinks != null) + { + foreach (var (path, target) in symlinks) + { + _storage.CreateSymlink(path, target, true); + } + } + } + + public NtStatus CreateFile( + string fileName, + FileAccess access, + FileShare share, + FileMode mode, + FileOptions options, + FileAttributes attributes, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + + if (SymlinkHandler.TryResolveSymlink(path, _storage, out var realPath) && realPath != null) + { + return SymlinkHandler.CreateRealFileOrDirectory(realPath, info.IsDirectory, mode); + } + + var node = _storage.GetNode(path); + var parentPath = FloppyStorage.GetParentPath(path); + var parentExists = _storage.Exists(parentPath); + + if (info.IsDirectory) + { + switch (mode) + { + case FileMode.CreateNew: + if (node != null) + { + return DokanResult.FileExists; + } + if (!parentExists) + { + return DokanResult.PathNotFound; + } + var newDir = _storage.CreateNode(path, true); + if (newDir == null) + { + return DokanResult.Error; + } + return DokanResult.Success; + + case FileMode.Open: + if (node == null) + { + return DokanResult.PathNotFound; + } + if (!node.IsDirectory) + { + return DokanResult.NotADirectory; + } + return DokanResult.Success; + + default: + return DokanResult.Success; + } + } + + var pathExists = node != null; + var pathIsDirectory = node?.IsDirectory ?? false; + + switch (mode) + { + case FileMode.Open: + if (!pathExists) + { + return DokanResult.FileNotFound; + } + if (pathIsDirectory) + { + info.IsDirectory = true; + } + break; + + case FileMode.CreateNew: + if (pathExists) + { + return DokanResult.FileExists; + } + if (!parentExists) + { + return DokanResult.PathNotFound; + } + _storage.CreateNode(path, false); + break; + + case FileMode.Create: + if (pathExists && pathIsDirectory) + { + return DokanResult.AccessDenied; + } + if (!parentExists) + { + return DokanResult.PathNotFound; + } + if (pathExists) + { + var existing = _storage.GetNode(path); + if (existing != null) + { + existing.Data = []; + existing.LastWriteTime = DateTime.Now; + } + } + else + { + _storage.CreateNode(path, false); + } + break; + + case FileMode.OpenOrCreate: + if (!pathExists) + { + if (!parentExists) + { + return DokanResult.PathNotFound; + } + _storage.CreateNode(path, false); + } + else if (pathIsDirectory) + { + info.IsDirectory = true; + } + break; + + case FileMode.Truncate: + if (!pathExists) + { + return DokanResult.FileNotFound; + } + if (pathIsDirectory) + { + return DokanResult.AccessDenied; + } + var truncNode = _storage.GetNode(path); + if (truncNode != null) + { + truncNode.Data = []; + truncNode.LastWriteTime = DateTime.Now; + } + break; + + case FileMode.Append: + if (!pathExists) + { + if (!parentExists) + { + return DokanResult.PathNotFound; + } + _storage.CreateNode(path, false); + } + break; + } + + return DokanResult.Success; + } + + public void Cleanup(string fileName, IDokanFileInfo info) + { + if (info.IsDirectory && _storage.Exists(FloppyStorage.NormalizePath(fileName))) + { + return; + } + } + + public void CloseFile(string fileName, IDokanFileInfo info) + { + } + + public NtStatus ReadFile( + string fileName, + byte[] buffer, + out int bytesRead, + long offset, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + + if (SymlinkHandler.TryResolveSymlink(path, _storage, out var realPath) && realPath != null) + { + return SymlinkHandler.ReadRealFile(realPath, buffer, out bytesRead, offset); + } + + var node = _storage.GetNode(path); + + if (node == null || node.IsDirectory) + { + bytesRead = 0; + return DokanResult.FileNotFound; + } + + node.LastAccessTime = DateTime.Now; + + if (offset >= node.Data.Length) + { + bytesRead = 0; + return DokanResult.Success; + } + + var toRead = Math.Min(buffer.Length, (int)(node.Data.Length - offset)); + Array.Copy(node.Data, offset, buffer, 0, toRead); + bytesRead = toRead; + + return DokanResult.Success; + } + + public NtStatus WriteFile( + string fileName, + byte[] buffer, + out int bytesWritten, + long offset, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + + if (SymlinkHandler.TryResolveSymlink(path, _storage, out var realPath) && realPath != null) + { + return SymlinkHandler.WriteRealFile(realPath, buffer, out bytesWritten, offset); + } + + var node = _storage.GetNode(path); + + if (node == null || node.IsDirectory) + { + bytesWritten = 0; + return DokanResult.FileNotFound; + } + + if (info.PagingIo) + { + if (offset >= node.Data.Length) + { + bytesWritten = 0; + return DokanResult.Success; + } + var toWrite = Math.Min(buffer.Length, (int)(node.Data.Length - offset)); + Array.Copy(buffer, 0, node.Data, offset, toWrite); + bytesWritten = toWrite; + return DokanResult.Success; + } + + var newLength = offset + buffer.Length; + var additionalBytes = newLength - node.Data.Length; + + if (additionalBytes > 0 && !_storage.HasEnoughSpace(additionalBytes)) + { + bytesWritten = 0; + return DokanResult.DiskFull; + } + + if (newLength > node.Data.Length) + { + var newData = new byte[newLength]; + Array.Copy(node.Data, newData, node.Data.Length); + node.Data = newData; + } + + Array.Copy(buffer, 0, node.Data, offset, buffer.Length); + bytesWritten = buffer.Length; + node.LastWriteTime = DateTime.Now; + + return DokanResult.Success; + } + + public NtStatus FlushFileBuffers(string fileName, IDokanFileInfo info) + { + return DokanResult.Success; + } + + public NtStatus GetFileInformation( + string fileName, + out FileInformation fileInfo, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + + if (SymlinkHandler.TryResolveSymlink(path, _storage, out var realPath) && realPath != null) + { + if (SymlinkHandler.GetRealFileInfo(realPath, out fileInfo)) + { + return DokanResult.Success; + } + return DokanResult.FileNotFound; + } + + var node = _storage.GetNode(path); + + if (node == null) + { + fileInfo = default; + return DokanResult.FileNotFound; + } + + fileInfo = new FileInformation + { + FileName = node.Name, + Attributes = node.Attributes, + CreationTime = node.CreationTime, + LastAccessTime = node.LastAccessTime, + LastWriteTime = node.LastWriteTime, + Length = node.FileSize + }; + + return DokanResult.Success; + } + + public NtStatus FindFiles( + string fileName, + out IList files, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + + if (SymlinkHandler.TryResolveSymlink(path, _storage, out var realPath) && realPath != null) + { + files = SymlinkHandler.ListRealDirectory(realPath); + return DokanResult.Success; + } + + var children = _storage.GetChildren(path); + + files = children.Select(node => new FileInformation + { + FileName = node.Name, + Attributes = node.Attributes, + CreationTime = node.CreationTime, + LastAccessTime = node.LastAccessTime, + LastWriteTime = node.LastWriteTime, + Length = node.FileSize + }).ToList(); + + return DokanResult.Success; + } + + public NtStatus FindFilesWithPattern( + string fileName, + string searchPattern, + out IList files, + IDokanFileInfo info) + { + files = []; + return DokanResult.NotImplemented; + } + + public NtStatus SetFileAttributes( + string fileName, + FileAttributes attributes, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null) + { + return DokanResult.FileNotFound; + } + + node.Attributes = attributes; + return DokanResult.Success; + } + + public NtStatus SetFileTime( + string fileName, + DateTime? creationTime, + DateTime? lastAccessTime, + DateTime? lastWriteTime, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null) + { + return DokanResult.FileNotFound; + } + + if (creationTime.HasValue) + { + node.CreationTime = creationTime.Value; + } + if (lastAccessTime.HasValue) + { + node.LastAccessTime = lastAccessTime.Value; + } + if (lastWriteTime.HasValue) + { + node.LastWriteTime = lastWriteTime.Value; + } + + return DokanResult.Success; + } + + public NtStatus DeleteFile(string fileName, IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null) + { + return DokanResult.FileNotFound; + } + + if (node.IsDirectory) + { + return DokanResult.AccessDenied; + } + + return DokanResult.Success; + } + + public NtStatus DeleteDirectory(string fileName, IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null) + { + return DokanResult.PathNotFound; + } + + if (!node.IsDirectory) + { + return DokanResult.NotADirectory; + } + + var children = _storage.GetChildren(path); + if (children.Any()) + { + return DokanResult.DirectoryNotEmpty; + } + + return DokanResult.Success; + } + + public NtStatus MoveFile( + string oldName, + string newName, + bool replace, + IDokanFileInfo info) + { + var success = _storage.MoveNode(oldName, newName, replace); + return success ? DokanResult.Success : DokanResult.FileNotFound; + } + + public NtStatus SetEndOfFile(string fileName, long length, IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null || node.IsDirectory) + { + return DokanResult.FileNotFound; + } + + var additionalBytes = length - node.Data.Length; + if (additionalBytes > 0 && !_storage.HasEnoughSpace(additionalBytes)) + { + return DokanResult.DiskFull; + } + + if (length != node.Data.Length) + { + var newData = new byte[length]; + var copyLength = Math.Min(length, node.Data.Length); + Array.Copy(node.Data, newData, copyLength); + node.Data = newData; + node.LastWriteTime = DateTime.Now; + } + + return DokanResult.Success; + } + + public NtStatus SetAllocationSize(string fileName, long length, IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null || node.IsDirectory) + { + return DokanResult.FileNotFound; + } + + if (length < node.Data.Length) + { + var newData = new byte[length]; + Array.Copy(node.Data, newData, length); + node.Data = newData; + node.LastWriteTime = DateTime.Now; + } + + return DokanResult.Success; + } + + public NtStatus LockFile( + string fileName, + long offset, + long length, + IDokanFileInfo info) + { + return DokanResult.Success; + } + + public NtStatus UnlockFile( + string fileName, + long offset, + long length, + IDokanFileInfo info) + { + return DokanResult.Success; + } + + public NtStatus GetDiskFreeSpace( + out long freeBytesAvailable, + out long totalNumberOfBytes, + out long totalNumberOfFreeBytes, + IDokanFileInfo info) + { + freeBytesAvailable = _storage.FreeSpace; + totalNumberOfBytes = _storage.Capacity; + totalNumberOfFreeBytes = _storage.FreeSpace; + return DokanResult.Success; + } + + public NtStatus GetVolumeInformation( + out string volumeLabel, + out FileSystemFeatures features, + out string fileSystemName, + out uint maximumComponentLength, + IDokanFileInfo info) + { + volumeLabel = _volumeName; + fileSystemName = FileSystemName; + maximumComponentLength = 255; + + features = FileSystemFeatures.CasePreservedNames + | FileSystemFeatures.UnicodeOnDisk; + + return DokanResult.Success; + } + + public NtStatus GetFileSecurity( + string fileName, + out FileSystemSecurity? security, + AccessControlSections sections, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null) + { + security = null; + return DokanResult.FileNotFound; + } + + security = node.Security; + return DokanResult.Success; + } + + public NtStatus SetFileSecurity( + string fileName, + FileSystemSecurity security, + AccessControlSections sections, + IDokanFileInfo info) + { + var path = FloppyStorage.NormalizePath(fileName); + var node = _storage.GetNode(path); + + if (node == null) + { + return DokanResult.FileNotFound; + } + + node.Security = security; + return DokanResult.Success; + } + + public NtStatus Mounted(string mountPoint, IDokanFileInfo info) + { + Console.WriteLine($"Drive mounted at {mountPoint}"); + return DokanResult.Success; + } + + public NtStatus Unmounted(IDokanFileInfo info) + { + Console.WriteLine("Drive unmounted"); + return DokanResult.Success; + } + + public NtStatus FindStreams( + string fileName, + out IList streams, + IDokanFileInfo info) + { + streams = []; + return DokanResult.NotImplemented; + } +} diff --git a/DevDrive/FloppyStorage.cs b/DevDrive/FloppyStorage.cs new file mode 100644 index 0000000..9d1cf0c --- /dev/null +++ b/DevDrive/FloppyStorage.cs @@ -0,0 +1,213 @@ +using System.Collections.Concurrent; + +namespace DevDrive; + +/// +/// Manages the in-memory file system storage for the virtual drive. +/// +public class FloppyStorage +{ + public const long DefaultCapacity = 1_474_560; + public const int SectorSize = 512; + public const int AllocationUnitSize = 512; + + private readonly ConcurrentDictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + public long Capacity { get; } + public long FreeSpace { get; } + + public FloppyStorage() : this(DefaultCapacity, DefaultCapacity) + { + } + + public FloppyStorage(long totalBytes, long freeBytes) + { + Capacity = totalBytes; + FreeSpace = freeBytes; + var root = new FloppyFileNode("\\", true); + _files["\\"] = root; + } + + public FloppyFileNode? CreateSymlink(string path, string targetPath, bool isDirectory) + { + path = NormalizePath(path); + + if (_files.ContainsKey(path)) + { + return null; + } + + var parentPath = GetParentPath(path); + if (!_files.ContainsKey(parentPath)) + { + return null; + } + + var node = new FloppyFileNode(path, isDirectory) + { + SymlinkTarget = targetPath, + Attributes = (isDirectory ? FileAttributes.Directory : FileAttributes.Normal) | FileAttributes.ReparsePoint + }; + _files[path] = node; + return node; + } + + public static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return "\\"; + } + + path = path.Replace('/', '\\'); + if (!path.StartsWith('\\')) + { + path = "\\" + path; + } + + while (path.Contains("\\\\")) + { + path = path.Replace("\\\\", "\\"); + } + + if (path.Length > 1 && path.EndsWith('\\')) + { + path = path.TrimEnd('\\'); + } + + return path; + } + + public static string GetParentPath(string path) + { + path = NormalizePath(path); + if (path == "\\") + { + return "\\"; + } + + var lastSlash = path.LastIndexOf('\\'); + return lastSlash <= 0 ? "\\" : path[..lastSlash]; + } + + public bool Exists(string path) + { + path = NormalizePath(path); + return _files.ContainsKey(path); + } + + public FloppyFileNode? GetNode(string path) + { + path = NormalizePath(path); + _files.TryGetValue(path, out var node); + return node; + } + + public FloppyFileNode? CreateNode(string path, bool isDirectory) + { + path = NormalizePath(path); + + if (_files.ContainsKey(path)) + { + return _files[path]; + } + + var parentPath = GetParentPath(path); + if (!_files.ContainsKey(parentPath)) + { + return null; + } + + var node = new FloppyFileNode(path, isDirectory); + _files[path] = node; + return node; + } + + public bool DeleteNode(string path) + { + path = NormalizePath(path); + + if (path == "\\") + { + return false; + } + + var node = GetNode(path); + if (node == null) + { + return false; + } + + if (node.IsDirectory) + { + var children = GetChildren(path); + if (children.Any()) + { + return false; + } + } + + return _files.TryRemove(path, out _); + } + + public IEnumerable GetChildren(string path) + { + path = NormalizePath(path); + + foreach (var kvp in _files) + { + var filePath = kvp.Key; + if (filePath == path) + { + continue; + } + + var parentPath = GetParentPath(filePath); + if (string.Equals(parentPath, path, StringComparison.OrdinalIgnoreCase)) + { + yield return kvp.Value; + } + } + } + + public bool MoveNode(string oldPath, string newPath, bool replace) + { + oldPath = NormalizePath(oldPath); + newPath = NormalizePath(newPath); + + if (!_files.TryGetValue(oldPath, out var node)) + { + return false; + } + + if (_files.ContainsKey(newPath)) + { + if (!replace) + { + return false; + } + _files.TryRemove(newPath, out _); + } + + var newParentPath = GetParentPath(newPath); + if (!_files.ContainsKey(newParentPath)) + { + return false; + } + + _files.TryRemove(oldPath, out _); + + node.FullPath = newPath; + node.Name = Path.GetFileName(newPath); + node.LastWriteTime = DateTime.Now; + + _files[newPath] = node; + return true; + } + + public bool HasEnoughSpace(long additionalBytes) + { + return FreeSpace >= additionalBytes; + } +} diff --git a/DevDrive/Program.cs b/DevDrive/Program.cs new file mode 100644 index 0000000..9583344 --- /dev/null +++ b/DevDrive/Program.cs @@ -0,0 +1,413 @@ +using System.Management; +using System.Runtime.Versioning; +using System.Text.Json; +using DokanNet; +using DokanNet.Logging; + +namespace DevDrive; + +/// +/// Entry point for the virtual drive application. +/// Monitors a config drive via WMI events and creates symlinks based on index.json. +/// +[SupportedOSPlatform("windows")] +public static class Program +{ + private const string DefaultMountPoint = "A:\\"; + private const string DefaultConfigDrive = "C:\\PhysicalDrive\\"; + private const string ConfigFile = "index.json"; + private const string LogPrefix = "[DevDrive]"; + + private static Dokan? _dokan; + private static DokanInstance? _dokanInstance; + private static readonly ManualResetEvent ShutdownEvent = new(false); + private static bool _isMounted; + private static string _configDrive = DefaultConfigDrive; + private static string _configFilePath = ""; + private static string _mountPoint = DefaultMountPoint; + private static FloppyConfig? _lastConfig; + private static readonly object MountLock = new(); + private static bool _isConfigDriveLetter; + + /// + /// Main entry point. + /// + /// Command line arguments. [0] = mount point, [1] = config drive. + public static void Main(string[] args) + { + _mountPoint = args.Length > 0 ? args[0] : DefaultMountPoint; + _configDrive = args.Length > 1 ? args[1] : DefaultConfigDrive; + + if (!_mountPoint.EndsWith("\\")) + { + _mountPoint += "\\"; + } + if (!_configDrive.EndsWith("\\")) + { + _configDrive += "\\"; + } + + _configFilePath = Path.Combine(_configDrive, ConfigFile); + _isConfigDriveLetter = _configDrive.Length <= 3 && _configDrive[1] == ':'; + + Console.WriteLine($"{LogPrefix} DevDrive"); + Console.WriteLine($"{LogPrefix} =================="); + Console.WriteLine($"{LogPrefix} Watching for config drive: {_configDrive}"); + Console.WriteLine($"{LogPrefix} Config file: {_configFilePath}"); + Console.WriteLine($"{LogPrefix} Mount point: {_mountPoint}"); + Console.WriteLine(); + Console.WriteLine($"{LogPrefix} Press Ctrl+C to exit."); + Console.WriteLine(); + + Console.CancelKeyPress += (sender, eventArgs) => + { + eventArgs.Cancel = true; + Console.WriteLine($"{LogPrefix} Shutting down..."); + ShutdownEvent.Set(); + }; + + try + { + _dokan = new Dokan(new ConsoleLogger(LogPrefix)); + + if (_isConfigDriveLetter) + { + StartWmiWatchers(); + } + else + { + StartFileSystemWatcher(); + } + + CheckAndMountIfReady(); + + ShutdownEvent.WaitOne(); + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Fatal error: {ex.Message}"); + Environment.Exit(1); + } + finally + { + UnmountFloppy(); + _dokan?.Dispose(); + } + } + + private static void StartFileSystemWatcher() + { + StartVolumeWatchers(); + + Console.WriteLine($"{LogPrefix} Volume watchers started for {_configDrive}"); + Console.WriteLine($"{LogPrefix} Watching for: {ConfigFile}"); + } + + private static void StartVolumeWatchers() + { + try + { + var insertQuery = new WqlEventQuery( + "SELECT * FROM __InstanceCreationEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_Volume'" + ); + var insertWatcher = new ManagementEventWatcher(insertQuery); + insertWatcher.EventArrived += (s, e) => + { + Console.WriteLine($"{LogPrefix} Volume added, checking config..."); + Thread.Sleep(1000); + CheckAndMountIfReady(); + }; + insertWatcher.Start(); + + var removeQuery = new WqlEventQuery( + "SELECT * FROM __InstanceDeletionEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_Volume'" + ); + var removeWatcher = new ManagementEventWatcher(removeQuery); + removeWatcher.EventArrived += (s, e) => + { + Console.WriteLine($"{LogPrefix} Volume removed"); + lock (MountLock) + { + if (_isMounted && !IsConfigAccessible()) + { + Console.WriteLine($"{LogPrefix} Config drive no longer accessible, unmounting..."); + UnmountFloppy(); + _lastConfig = null; + } + } + }; + removeWatcher.Start(); + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Warning: Could not start WMI volume watchers: {ex.Message}"); + } + } + + private static bool IsConfigAccessible() + { + try + { + if (!Directory.Exists(_configDrive)) + { + return false; + } + + var _ = Directory.GetFiles(_configDrive); + return File.Exists(_configFilePath); + } + catch + { + return false; + } + } + + private static void StartWmiWatchers() + { + var driveLetter = _configDrive.TrimEnd('\\'); + + var insertQuery = new WqlEventQuery( + "SELECT * FROM __InstanceCreationEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_LogicalDisk'" + ); + var insertWatcher = new ManagementEventWatcher(insertQuery); + insertWatcher.EventArrived += OnDriveInserted; + insertWatcher.Start(); + + var removeQuery = new WqlEventQuery( + "SELECT * FROM __InstanceDeletionEvent WITHIN 1 WHERE TargetInstance ISA 'Win32_LogicalDisk'" + ); + var removeWatcher = new ManagementEventWatcher(removeQuery); + removeWatcher.EventArrived += OnDriveRemoved; + removeWatcher.Start(); + + Console.WriteLine($"{LogPrefix} WMI watchers started (event-based, no polling)"); + Console.WriteLine($"{LogPrefix} Watching for drive: {driveLetter}"); + } + + private static void OnDriveInserted(object sender, EventArrivedEventArgs e) + { + try + { + var targetInstance = (ManagementBaseObject)e.NewEvent["TargetInstance"]; + var deviceId = targetInstance["DeviceID"]?.ToString(); + + if (string.IsNullOrEmpty(deviceId)) + { + return; + } + + var insertedDrive = deviceId + "\\"; + + if (string.Equals(insertedDrive, _configDrive, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(); + Console.WriteLine($"{LogPrefix} Drive {deviceId} inserted!"); + + Thread.Sleep(500); + CheckAndMountIfReady(); + } + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Error handling drive insert: {ex.Message}"); + } + } + + private static void OnDriveRemoved(object sender, EventArrivedEventArgs e) + { + try + { + var targetInstance = (ManagementBaseObject)e.NewEvent["TargetInstance"]; + var deviceId = targetInstance["DeviceID"]?.ToString(); + + if (string.IsNullOrEmpty(deviceId)) + { + return; + } + + var removedDrive = deviceId + "\\"; + + if (string.Equals(removedDrive, _configDrive, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine(); + Console.WriteLine($"{LogPrefix} Drive {deviceId} removed!"); + + lock (MountLock) + { + if (_isMounted) + { + Console.WriteLine($"{LogPrefix} Config drive disconnected, unmounting..."); + UnmountFloppy(); + _lastConfig = null; + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Error handling drive removal: {ex.Message}"); + } + } + + private static void CheckAndMountIfReady() + { + lock (MountLock) + { + var driveExists = Directory.Exists(_configDrive); + var configExists = driveExists && File.Exists(_configFilePath); + + if (!driveExists) + { + Console.WriteLine($"{LogPrefix} Waiting for {_configDrive} to be connected..."); + return; + } + + if (!configExists) + { + Console.WriteLine($"{LogPrefix} Drive present but {ConfigFile} not found"); + return; + } + + var config = ReadConfig(_configFilePath); + + if (config == null || config.Paths.Count == 0) + { + Console.WriteLine($"{LogPrefix} Config empty or invalid"); + + if (_isMounted) + { + UnmountFloppy(); + _lastConfig = null; + } + return; + } + + var configChanged = !ConfigEquals(_lastConfig, config); + + if (!_isMounted || configChanged) + { + if (_isMounted && configChanged) + { + Console.WriteLine($"{LogPrefix} Config changed, remounting..."); + UnmountFloppy(); + } + + MountFloppy(config); + _lastConfig = config; + } + } + } + + private static FloppyConfig? ReadConfig(string configFilePath) + { + try + { + var json = File.ReadAllText(configFilePath); + return JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Error reading config: {ex.Message}"); + return null; + } + } + + private static bool ConfigEquals(FloppyConfig? a, FloppyConfig? b) + { + if (a == null || b == null) + { + return a == b; + } + + if (a.Paths.Count != b.Paths.Count) + { + return false; + } + + for (var i = 0; i < a.Paths.Count; i++) + { + if (a.Paths[i].Name != b.Paths[i].Name || a.Paths[i].Path != b.Paths[i].Path) + { + return false; + } + } + + return true; + } + + private static void MountFloppy(FloppyConfig config) + { + if (_dokan == null) + { + return; + } + + try + { + var symlinks = new Dictionary(); + Console.WriteLine(); + Console.WriteLine($"{LogPrefix} Creating symlinks:"); + + foreach (var entry in config.Paths) + { + var symlinkPath = "\\" + entry.Name; + symlinks[symlinkPath] = entry.Path; + Console.WriteLine($"{LogPrefix} {entry.Name} -> {entry.Path}"); + } + + Console.WriteLine(); + + var totalBytes = (long)(config.DriveTotalMB * 1024 * 1024); + var freeBytes = (long)(config.DriveFreeMB * 1024 * 1024); + Console.WriteLine($"{LogPrefix} Drive size: {config.DriveTotalMB} MB total, {config.DriveFreeMB} MB free"); + var floppyFs = new FloppyFileSystem(symlinks, config.DriveName, totalBytes, freeBytes); + + var dokanBuilder = new DokanInstanceBuilder(_dokan) + .ConfigureOptions(options => + { + options.Options = DokanOptions.MountManager | DokanOptions.CurrentSession; + options.MountPoint = _mountPoint; + options.SingleThread = false; + options.AllocationUnitSize = FloppyStorage.AllocationUnitSize; + options.SectorSize = FloppyStorage.SectorSize; + }); + + _dokanInstance = dokanBuilder.Build(floppyFs); + _isMounted = true; + + Console.WriteLine($"{LogPrefix} Mounted at {_mountPoint}"); + + var iconPath = string.IsNullOrWhiteSpace(config.DriveIcon) + ? "%SystemRoot%\\System32\\shell32.dll,6" + : config.DriveIcon; + DriveIconHelper.SetDriveIcon(_mountPoint, iconPath, config.DriveName); + } + catch (DokanException ex) + { + Console.WriteLine($"{LogPrefix} Mount error: {ex.Message}"); + _isMounted = false; + } + } + + private static void UnmountFloppy() + { + if (!_isMounted) + { + return; + } + + try + { + DriveIconHelper.RemoveDriveIcon(); + _dokan?.RemoveMountPoint(_mountPoint); + _dokanInstance?.Dispose(); + _dokanInstance = null; + _isMounted = false; + Console.WriteLine($"{LogPrefix} Unmounted"); + } + catch (Exception ex) + { + Console.WriteLine($"{LogPrefix} Unmount error: {ex.Message}"); + } + } +} diff --git a/DevDrive/SymlinkHandler.cs b/DevDrive/SymlinkHandler.cs new file mode 100644 index 0000000..0b21381 --- /dev/null +++ b/DevDrive/SymlinkHandler.cs @@ -0,0 +1,201 @@ +using DokanNet; + +namespace DevDrive; + +/// +/// Handles passthrough operations for symlinks to real file system paths. +/// +public static class SymlinkHandler +{ + public static bool TryResolveSymlink(string virtualPath, FloppyStorage storage, out string? realPath) + { + realPath = null; + virtualPath = FloppyStorage.NormalizePath(virtualPath); + + var node = storage.GetNode(virtualPath); + if (node?.IsSymlink == true) + { + realPath = node.SymlinkTarget; + return true; + } + + var parts = virtualPath.Split('\\', StringSplitOptions.RemoveEmptyEntries); + var currentPath = "\\"; + + for (var i = 0; i < parts.Length; i++) + { + currentPath = currentPath == "\\" ? "\\" + parts[i] : currentPath + "\\" + parts[i]; + var currentNode = storage.GetNode(currentPath); + + if (currentNode?.IsSymlink == true) + { + var remainingPath = string.Join("\\", parts.Skip(i + 1)); + realPath = string.IsNullOrEmpty(remainingPath) + ? currentNode.SymlinkTarget + : Path.Combine(currentNode.SymlinkTarget!, remainingPath); + return true; + } + } + + return false; + } + + public static IList ListRealDirectory(string realPath) + { + var files = new List(); + + try + { + if (!Directory.Exists(realPath)) + { + return files; + } + + foreach (var entry in new DirectoryInfo(realPath).EnumerateFileSystemInfos()) + { + files.Add(new FileInformation + { + FileName = entry.Name, + Attributes = entry.Attributes, + CreationTime = entry.CreationTime, + LastAccessTime = entry.LastAccessTime, + LastWriteTime = entry.LastWriteTime, + Length = entry is FileInfo fi ? fi.Length : 0 + }); + } + } + catch + { + } + + return files; + } + + public static bool GetRealFileInfo(string realPath, out FileInformation fileInfo) + { + fileInfo = default; + + try + { + if (File.Exists(realPath)) + { + var fi = new FileInfo(realPath); + fileInfo = new FileInformation + { + FileName = fi.Name, + Attributes = fi.Attributes, + CreationTime = fi.CreationTime, + LastAccessTime = fi.LastAccessTime, + LastWriteTime = fi.LastWriteTime, + Length = fi.Length + }; + return true; + } + + if (Directory.Exists(realPath)) + { + var di = new DirectoryInfo(realPath); + fileInfo = new FileInformation + { + FileName = di.Name, + Attributes = di.Attributes, + CreationTime = di.CreationTime, + LastAccessTime = di.LastAccessTime, + LastWriteTime = di.LastWriteTime, + Length = 0 + }; + return true; + } + } + catch + { + } + + return false; + } + + public static NtStatus ReadRealFile(string realPath, byte[] buffer, out int bytesRead, long offset) + { + bytesRead = 0; + + try + { + using var stream = new FileStream(realPath, System.IO.FileMode.Open, System.IO.FileAccess.Read, FileShare.ReadWrite); + stream.Seek(offset, SeekOrigin.Begin); + bytesRead = stream.Read(buffer, 0, buffer.Length); + return DokanResult.Success; + } + catch (FileNotFoundException) + { + return DokanResult.FileNotFound; + } + catch (UnauthorizedAccessException) + { + return DokanResult.AccessDenied; + } + catch + { + return DokanResult.Error; + } + } + + public static NtStatus WriteRealFile(string realPath, byte[] buffer, out int bytesWritten, long offset) + { + bytesWritten = 0; + + try + { + using var stream = new FileStream(realPath, System.IO.FileMode.OpenOrCreate, System.IO.FileAccess.Write, FileShare.ReadWrite); + stream.Seek(offset, SeekOrigin.Begin); + stream.Write(buffer, 0, buffer.Length); + bytesWritten = buffer.Length; + return DokanResult.Success; + } + catch (UnauthorizedAccessException) + { + return DokanResult.AccessDenied; + } + catch + { + return DokanResult.Error; + } + } + + public static NtStatus CreateRealFileOrDirectory(string realPath, bool isDirectory, FileMode mode) + { + try + { + if (isDirectory) + { + if (mode == FileMode.CreateNew) + { + Directory.CreateDirectory(realPath); + } + return DokanResult.Success; + } + + switch (mode) + { + case FileMode.CreateNew: + using (File.Create(realPath)) { } + break; + case FileMode.Create: + case FileMode.OpenOrCreate: + using (new FileStream(realPath, System.IO.FileMode.OpenOrCreate, System.IO.FileAccess.ReadWrite)) { } + break; + default: + break; + } + + return DokanResult.Success; + } + catch (UnauthorizedAccessException) + { + return DokanResult.AccessDenied; + } + catch + { + return DokanResult.Error; + } + } +}