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;