Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ Data flows: `MountManager` → `RamDisk.Create()` → `MemoryFileSystem` + `File
- `MemoryFileSystem : FileSystemBase` — implements all WinFsp callbacks (`Create`, `Open`, `Read`, `Write`, `Rename`, `CanDelete`, `ReadDirectoryEntry`, etc.). Enforces capacity ceiling; returns `STATUS_DISK_FULL` when exceeded. In read-only mode, all mutating operations return `STATUS_MEDIA_WRITE_PROTECTED`. `ReadDirectoryEntry` builds a snapshot list on the first call (including `.` and `..`), then iterates it statelessly on subsequent calls. `Init()` auto-creates the root directory `\` with a standard security descriptor. `FileSystemHost` is configured with 4 KB sector/allocation size, `FileInfoTimeout=1000 ms`, `CasePreservedNames=true`, `CaseSensitiveSearch=false`, and a randomized `VolumeSerialNumber`. Note: `FileNode.AllocationUnit` (the actual byte-alignment granularity for `AllocationSize` rounding) is 512 bytes, not 4 KB — don't conflate the two.
- **Dirty tracking** — every mutating callback (`Create`, `Write`, `Overwrite`, `SetFileSize`, `SetBasicInfo`, `SetSecurity`, `SetVolumeLabel`, `Rename`, and the delete/timestamp/allocation-size branches of `Cleanup`) calls `MarkDirty()` on success, setting an internal `volatile bool`. `RamDisk.Format()` bypasses these callbacks (calls `_fs.NodeMap.ClearAll()` directly) so it calls `_fs.MarkDirty()` itself. `IsDirty`/`ClearDirty()` are exposed internally for `RamDisk` to gate periodic auto-save (see below). Read-only callbacks never mark dirty.
- `RamDisk` — wraps `MemoryFileSystem` + `FileSystemHost`. `RamDisk.Create(options)` mounts the volume; `Dispose()` unmounts. After mounting, polls `DriveInfo.GetDrives()` up to 25 × 100 ms (2.5 s total) until the drive letter appears, then broadcasts `SHCNE_DRIVEADD` via `SHChangeNotify` so Explorer refreshes immediately. If a loaded image's actual content (`FileNodeMap.GetTotalAllocated()`) exceeds the capacity that would otherwise apply, `Create` silently raises the effective capacity to fit the existing data instead of failing the mount or leaving the disk permanently over capacity — the pre-adjustment value is recorded in `RamDisk.OriginalCapacityBytesOnLoad` (a one-time mount-time diagnostic, not persisted back to `Options` history or any saved profile). `DiskViewModel` mirrors this via `CapacityAdjustedOnLoad`/`OriginalCapacityBytesOnLoad`, and `App.xaml.cs`'s `Disks.CollectionChanged` handler (the same spot that wires up `SaveFailed`/`HighUsageWarning`) checks it once per newly-added disk and surfaces a tray balloon + status-bar message (`Tray.CapacityAdjustedTitle/Body`, `Status.CapacityAdjusted`) if it fired.
- **Auto-save** (`RamDisk`) — when `DiskOptions.AutoSaveIntervalMinutes` and `PersistImagePath` are both set, `ConfigureAutoSaveTimer()` (called from `Create()` and `TryApplyOptions()`) starts a `System.Threading.Timer` with `dueTime=TimeSpan.Zero` so the first save fires immediately on a background thread, then repeats every interval. `TryAutoSave()` guards against overlapping saves with a non-blocking `_autoSaveLock.TryEnter()` (a C# 13 `Lock`) — if a save is already running, that tick is skipped rather than queued. It also skips the save entirely (no disk I/O) when `!_fs.IsDirty && Options.PersistImagePath == _lastSavedImagePath` — i.e. nothing changed since the last save and the target path hasn't changed either; manual saves stay unconditional. `Dispose()` takes the same lock with a blocking `lock` statement (waits for any in-flight save, then performs the same dirty/path check before writing) before unmounting, so a disk is never left unsaved on unmount/edit-remount/app-exit, without performing a redundant write when nothing changed. `SaveToImage()` clears the dirty flag and records `_lastSavedImagePath` after every successful save, regardless of caller.
- **Auto-save** (`RamDisk`) — when `DiskOptions.AutoSaveIntervalMinutes` and `PersistImagePath` are both set, `ConfigureAutoSaveTimer()` (called from `Create()` and `TryApplyOptions()`) starts a `System.Threading.Timer` with `dueTime=TimeSpan.Zero` so the first save fires immediately on a background thread, then repeats every interval. `TryAutoSave()` guards against overlapping saves with a non-blocking `_autoSaveLock.TryEnter()` (a C# 13 `Lock`) — if a save is already running, that tick is skipped rather than queued. The shared skip condition — `!_fs.IsDirty && Options.PersistImagePath == _lastSavedImagePath`, i.e. nothing changed since the last save and the target path hasn't changed either — is centralized in the private `NeedsSave()` helper and checked by `TryAutoSave`, `Dispose()`, and `SaveToImageSafe()` alike; manual saves (`SaveToImage()` directly) stay unconditional. `Dispose()` takes the same lock with a blocking `lock` statement (waits for any in-flight save, then checks `NeedsSave()` before writing) before unmounting, so a disk is never left unsaved on unmount/edit-remount/app-exit, without performing a redundant write when nothing changed. `SaveToImage()` clears the dirty flag and records `_lastSavedImagePath` after every successful save, regardless of caller. `SaveToImageSafe()` wraps `SaveToImage()` with the `NeedsSave()` skip plus exception swallowing (logs and returns rather than throwing), for callers that must not fail loudly — see `OnSessionEnding` below.
- **OS shutdown save path** (`App.xaml.cs`) — subscribes to `SystemEvents.SessionEnding` (Windows logoff/shutdown, distinct from the app's own tray-exit path). `OnSessionEnding` fires `disk.SaveToImageSafe()` for every mounted disk concurrently (one `Task.Run` each, not sequential) and blocks with `Task.WaitAll(saveTasks, SessionEndingSaveTimeout)` (10 s) so a single stuck save can't block the OS shutdown callback indefinitely; disks with nothing to save are skipped via `NeedsSave()` inside `SaveToImageSafe()` rather than spawning a task at all.
- `DiskImageSerializer` — reads/writes the `.mdr` binary format (magic `MDRD`, little-endian; version currently `3`). In v3, capacity and volume label are always plaintext header fields; only the node region (node count + node entries, including security descriptors) is gzip-compressed whenever `ImageCompressionLevel != None`, and AES-256-GCM-encrypted on top of that when the image is password-protected. `Save()` creates any missing parent directories before opening the `FileStream`. `Load()` also reads legacy version-1 (no compression) and version-2 (the whole node region compressed, no encryption) images for backward compatibility — do not remove those branches; the "compress everything after the header" behavior is legacy-only, not how v3 works. `PeekHeader()` reads capacity/label/`isEncrypted` without needing a password, since capacity and label stay plaintext in the header even on an encrypted image.
- **Image encryption** — optional per-disk password protection via envelope encryption: a random 256-bit content-encryption key (CEK, from `DiskImageSerializer.GenerateCek()`) encrypts the node region with AES-256-GCM; the user's password only wraps/unwraps that CEK (PBKDF2-SHA256, 210,000 iterations, 16-byte salt, then AES-256-GCM key-wrap) via an `ImageEncryptionInfo` record struct (`Password` + `Cek`) passed into `Save`/`Load`. Changing the password just re-wraps the CEK — the node data is never re-encrypted. `ImageEncryptionExceptions.cs` defines `ImagePasswordRequiredException` (no password supplied for an encrypted image) and `ImagePasswordIncorrectException` (wrong password / GCM tag mismatch), thrown from `Load`.
- `MountManager` — thread-safe registry of active `RamDisk` instances. `Mount(options)` calls `RamDisk.Create`; `Unmount(mountPoint)` disposes the disk. Fires `DiskMounted` / `DiskUnmounted` events consumed by the App layer to update the UI.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Create, mount and manage in-memory volumes that appear as normal drive letters i
- Selectable image compression (Off / Fast / Balanced / Max, default Fast)
- Snapshot / version history — cap retained snapshots by count and/or size; deduplicated by content hash so many snapshots cost little extra space; restore any snapshot via **Restore Snapshot...**
- Clone a disk onto another mounted disk or export it to a new `.mdr` file (**Clone Disk...**)
- Optional password protection for `.mdr` images (AES-256-GCM envelope encryption; the password only wraps a random per-disk key, so changing the password never re-encrypts the file contents) — set from the "Encrypt Image" option in the disk dialog; prompted for on mount (including auto-mount at startup) whenever an image is encrypted
- Optional password protection for `.mdr` images (AES-256-GCM envelope encryption; the password only wraps a random per-disk key, so changing the password never re-encrypts the file contents) — set from the "Encrypt Image" option in the disk dialog (password must be 8–64 characters); prompted for on mount (including auto-mount at startup) whenever an image is encrypted

**CLI**
- `mdrive` command-line tool (ships alongside `ManagedDrive.exe`) for scripting mount/unmount/format/save/list/exit against the running app, forwarded over a named pipe
Expand Down Expand Up @@ -328,7 +328,7 @@ This project bundles [WinFsp](https://winfsp.dev/) and [SharpCompress](https://g
- 可选镜像压缩级别(不压缩/快速/均衡/最高,默认快速)
- 快照/版本历史——按数量和/或大小上限保留快照,相同内容跨快照去重存储,占用空间远小于逻辑大小之和;可随时通过**还原快照...**还原到某个历史版本
- 克隆磁盘到另一已挂载磁盘,或导出为新的 `.mdr` 文件(**克隆磁盘...**)
- 可选的 `.mdr` 镜像密码保护(AES-256-GCM 信封加密;密码仅用于加密一个随机生成的每盘专属密钥,因此更改密码无需重新加密文件内容)——在磁盘对话框中通过"加密镜像"选项设置;挂载时(包括启动时的自动挂载)若镜像已加密会提示输入密码
- 可选的 `.mdr` 镜像密码保护(AES-256-GCM 信封加密;密码仅用于加密一个随机生成的每盘专属密钥,因此更改密码无需重新加密文件内容)——在磁盘对话框中通过"加密镜像"选项设置(密码长度需为 8–64 位);挂载时(包括启动时的自动挂载)若镜像已加密会提示输入密码

**便利与安全**
- 可选的资源管理器右键集成:在设置中启用后,会为 zip/7z/rar/tar 压缩包添加右键菜单项**"挂载为内存盘 (ManagedDrive)"**,一键挂载,无需先打开应用——若 `ManagedDrive.exe` 尚未运行会自动启动,挂载完成后会自动打开该盘符的资源管理器窗口
Expand Down
2 changes: 2 additions & 0 deletions src/ManagedDrive.App/Localization/Strings.en-US.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@
<sys:String x:Key="CreateDisk.ConfirmPassword">Confirm:</sys:String>
<sys:String x:Key="Val.PasswordRequired">Enter a password, or uncheck "Encrypt image".</sys:String>
<sys:String x:Key="Val.PasswordMismatch">The password and confirmation do not match.</sys:String>
<sys:String x:Key="Val.PasswordTooShort">Password must be at least {0} characters.</sys:String>
<sys:String x:Key="Val.PasswordTooLong">Password must be at most {0} characters.</sys:String>
<sys:String x:Key="Val.PasswordIncorrect">Incorrect password.</sys:String>
<sys:String x:Key="Val.CliPasswordRequired">This image is password-protected. Supply --password or --password-file.</sys:String>
<sys:String x:Key="Val.CliPasswordIncorrect">The supplied password is incorrect.</sys:String>
Expand Down
2 changes: 2 additions & 0 deletions src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@
<sys:String x:Key="CreateDisk.ConfirmPassword">确认密码:</sys:String>
<sys:String x:Key="Val.PasswordRequired">请输入密码,或取消勾选“加密镜像”。</sys:String>
<sys:String x:Key="Val.PasswordMismatch">两次输入的密码不一致。</sys:String>
<sys:String x:Key="Val.PasswordTooShort">密码长度不能少于 {0} 位。</sys:String>
<sys:String x:Key="Val.PasswordTooLong">密码长度不能超过 {0} 位。</sys:String>
<sys:String x:Key="Val.PasswordIncorrect">密码不正确。</sys:String>
<sys:String x:Key="Val.CliPasswordRequired">该镜像已加密,请提供 --password 或 --password-file。</sys:String>
<sys:String x:Key="Val.CliPasswordIncorrect">提供的密码不正确。</sys:String>
Expand Down
23 changes: 19 additions & 4 deletions src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ namespace ManagedDrive.App.Views;
/// </summary>
public partial class CreateDiskDialog
{
private static readonly List<ImageCompressionLevel> CompressionLevels =
[
private const int MaxPasswordLength = 64;

private const int MinPasswordLength = 8;

private static readonly List<ImageCompressionLevel> CompressionLevels = [
ImageCompressionLevel.None,
ImageCompressionLevel.Fastest,
ImageCompressionLevel.Optimal,
Expand Down Expand Up @@ -99,8 +102,8 @@ public CreateDiskDialog(DiskOptions existing, IReadOnlyList<DiskOptions>? otherD
EncryptImageBox.IsChecked = _wasEncrypted;
if (_wasEncrypted)
{
PasswordBox1.Password = currentPassword;
PasswordBox2.Password = currentPassword;
PasswordBox1.Password = currentPassword ?? string.Empty;
PasswordBox2.Password = currentPassword ?? string.Empty;
}

DriveLetterBox.Items.Clear();
Expand Down Expand Up @@ -856,6 +859,18 @@ private bool TryResolvePassword(out string error)
return true;
}

if (password1.Length < MinPasswordLength)
{
error = Loc.Format("Val.PasswordTooShort", MinPasswordLength);
return false;
}

if (password1.Length > MaxPasswordLength)
{
error = Loc.Format("Val.PasswordTooLong", MaxPasswordLength);
return false;
}

Password = password1;
PasswordChanged = true;
error = string.Empty;
Expand Down
Loading