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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ 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. 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.
- **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, checked directly by `TryAutoSave`; manual saves (`SaveToImage()` directly) stay unconditional. `SaveToImage()` clears the dirty flag and records `_lastSavedImagePath` after every successful save, regardless of caller.
- **Save-on-exit opt-out** — `DiskOptions.SaveImageOnExit` (`bool`, default `true`) gates whether a disk is saved on app exit / OS shutdown, independent of periodic auto-save. The private `NeedsExitSave()` helper (`Options.SaveImageOnExit && NeedsSave()`) layers this on top of the shared skip condition and is what `Dispose()` and `SaveToImageSafe()` actually check (not `NeedsSave()` directly) — a disk with save-on-exit disabled is left untouched on unmount/edit-remount/app-exit/OS shutdown even if dirty, while periodic auto-save saves it as usual. `Dispose()` takes `_autoSaveLock` with a blocking `lock` statement (waits for any in-flight save, then checks `NeedsExitSave()` before writing) before unmounting. `SaveToImageSafe()` wraps `SaveToImage()` with the `NeedsExitSave()` 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 `NeedsExitSave()` 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
Loading
Loading