diff --git a/CLAUDE.md b/CLAUDE.md
index 7867d5e..802d4df 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/README.md b/README.md
index 693d24d..0de67ca 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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` 尚未运行会自动启动,挂载完成后会自动打开该盘符的资源管理器窗口
diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml
index 6c52e0f..5912636 100644
--- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml
+++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml
@@ -119,6 +119,8 @@
Confirm:
Enter a password, or uncheck "Encrypt image".
The password and confirmation do not match.
+ Password must be at least {0} characters.
+ Password must be at most {0} characters.
Incorrect password.
This image is password-protected. Supply --password or --password-file.
The supplied password is incorrect.
diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
index 81104d4..4ff1f25 100644
--- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
+++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml
@@ -119,6 +119,8 @@
确认密码:
请输入密码,或取消勾选“加密镜像”。
两次输入的密码不一致。
+ 密码长度不能少于 {0} 位。
+ 密码长度不能超过 {0} 位。
密码不正确。
该镜像已加密,请提供 --password 或 --password-file。
提供的密码不正确。
diff --git a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs
index 4b42927..2ae5172 100644
--- a/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs
+++ b/src/ManagedDrive.App/Views/CreateDiskDialog.xaml.cs
@@ -8,8 +8,11 @@ namespace ManagedDrive.App.Views;
///
public partial class CreateDiskDialog
{
- private static readonly List CompressionLevels =
- [
+ private const int MaxPasswordLength = 64;
+
+ private const int MinPasswordLength = 8;
+
+ private static readonly List CompressionLevels = [
ImageCompressionLevel.None,
ImageCompressionLevel.Fastest,
ImageCompressionLevel.Optimal,
@@ -99,8 +102,8 @@ public CreateDiskDialog(DiskOptions existing, IReadOnlyList? 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();
@@ -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;