diff --git a/docs/knowledgebase/managed-deployment.md b/docs/knowledgebase/managed-deployment.md index 140a97de..e9bd47f9 100644 --- a/docs/knowledgebase/managed-deployment.md +++ b/docs/knowledgebase/managed-deployment.md @@ -41,6 +41,13 @@ Tools menu **Managed Deployment Status** and **Purge Managed Deployments**; mod- **Deployed** badge; context menu **Uninstall Managed Deployment** when a manifest exists. Purge/uninstall are blocked while `ManagedInstallSession.Current` is set. +`[REPO]` Patcher provenance (parity U6): when a managed component runs a Patcher +instruction, ModSync snapshots game-dir file hashes before install, diffs after +staged deploy, and merges added/changed paths into the component manifest via +`DeploymentService.RecordLiveGameFilesAsync` so uninstall can delete those files. +In-place overwrite **restore** of pre-patcher bytes (full ImmutableCheckpoint CAS) +is still deferred. + ## Validation vs managed installs (parity U4 decision B) `[REPO]` **Decision (2026-07-17):** keep classic VFS DryRun as-is; do **not** redirect @@ -59,8 +66,9 @@ DryRun through managed staging in this release. When `managedDeploymentEnabled` Full managed DryRun/VFS staging parity remains an optional future unit (plan option A). See [validation-pipeline.md](validation-pipeline.md#managed-deployment-caveat). -`[OPEN]` Still deferred: patcher provenance (ImmutableCheckpoint), optional managed -VFS DryRun redirect (U4 option A). Follow-up plan: +`[OPEN]` Still deferred: full ImmutableCheckpoint CAS restore for in-place patcher +overwrites (U6 records live added/changed paths for uninstall-delete; pre-image +restore via CAS remains optional). Follow-up plan: [docs/plans/2026-07-13-004-managed-deployment-validation-plan.md](../plans/2026-07-13-004-managed-deployment-validation-plan.md). Tests: `DeploymentServiceTests`, `ManagedInstallSessionTests`, `ModSyncSettingsTests`, diff --git a/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md b/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md index 6d4b1f90..b52527ff 100644 --- a/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md +++ b/docs/plans/2026-07-16-001-three-project-parity-architecture-plan.md @@ -357,7 +357,7 @@ Gate itself remains **U7 / #170** until that PR lands — do not bypass it when | U3 CLI `--profile` + shared session polish | In PR (CLI `--managed`/`--no-managed`/`--profile`) | | U4 Managed VFS / validation parity | Done (decision B — document classic DryRun caveat; option A deferred) | | U5 Uninstall / purge GUI | Done ([#179](https://github.com/oldrepublicwizard/ModSync/pull/179)) | -| U6 Patcher provenance (ImmutableCheckpoint) | Deferred | +| U6 Patcher provenance (ImmutableCheckpoint) | In PR (live game-file hash diff → manifest; CAS restore for in-place overwrites deferred) | | U7 FOMOD gate (#170) + living-plan sync | Deferred (open PR) | | U8 `modsync://` Phase 2 OS consume | Deferred | | U9 Guide ports fully on master | Deferred | diff --git a/src/ModSync.Core/ModComponent.cs b/src/ModSync.Core/ModComponent.cs index a8450e56..f1502272 100644 --- a/src/ModSync.Core/ModComponent.cs +++ b/src/ModSync.Core/ModComponent.cs @@ -836,6 +836,15 @@ public async Task InstallAsync( try { + ManagedInstallSession managedSession = ManagedInstallSession.Current; + IReadOnlyDictionary preInstallIndex = null; + if (managedSession != null) + { + preInstallIndex = await managedSession + .CaptureGameFileHashIndexAsync(cancellationToken) + .ConfigureAwait(false); + } + var realFileSystem = new Services.FileSystem.RealFileSystemProvider(); InstallExitCode exitCode = await ExecuteInstructionsAsync( Instructions, @@ -855,10 +864,15 @@ public async Task InstallAsync( if (exitCode == InstallExitCode.Success) { - ManagedInstallSession managedSession = ManagedInstallSession.Current; if (managedSession != null) { exitCode = await managedSession.DeployComponentAsync(this, cancellationToken).ConfigureAwait(false); + if (exitCode == InstallExitCode.Success) + { + await managedSession + .RecordPatcherProvenanceAsync(this, preInstallIndex, cancellationToken) + .ConfigureAwait(false); + } } } diff --git a/src/ModSync.Core/Services/Deployment/DeploymentService.cs b/src/ModSync.Core/Services/Deployment/DeploymentService.cs index 27f9d98b..0f8c0077 100644 --- a/src/ModSync.Core/Services/Deployment/DeploymentService.cs +++ b/src/ModSync.Core/Services/Deployment/DeploymentService.cs @@ -181,6 +181,153 @@ await Logger.LogAsync( return manifest; } + /// + /// Records files that already exist in the game directory (e.g. HoloPatcher + /// outputs) into the component's deployment manifest so managed uninstall + /// can remove them. Merges with any existing staged-deploy manifest. + /// Does not restore pre-patcher bytes for in-place overwrites in this slice — + /// uninstall deletes matching hashes only. + /// + [ItemNotNull] + public async Task RecordLiveGameFilesAsync( + Guid componentGuid, + [CanBeNull] string componentName, + [NotNull] IEnumerable relativePaths, + CancellationToken cancellationToken = default) + { + if (relativePaths is null) + { + throw new ArgumentNullException(nameof(relativePaths)); + } + + DeploymentManifest manifest; + if (TryGetManifest(componentGuid, out DeploymentManifest existing) && existing != null) + { + manifest = existing; + if (!string.IsNullOrWhiteSpace(componentName)) + { + manifest.ComponentName = componentName; + } + + manifest.DeployedUtc = DateTime.UtcNow; + } + else + { + manifest = new DeploymentManifest + { + ComponentGuid = componentGuid, + ComponentName = componentName, + DeployedUtc = DateTime.UtcNow, + }; + } + + var existingPaths = new HashSet( + manifest.Entries + .Where(e => !string.IsNullOrWhiteSpace(e.RelativePath)) + .Select(e => e.RelativePath), + StringComparer.OrdinalIgnoreCase); + + int recorded = 0; + foreach (string rawRelative in relativePaths.Distinct(StringComparer.OrdinalIgnoreCase)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string relativePath = NormalizeRelativePath(rawRelative ?? string.Empty); + if (string.IsNullOrWhiteSpace(relativePath) || existingPaths.Contains(relativePath)) + { + continue; + } + + if (!TryResolveConfinedGamePath(relativePath, out string gamePath) || !File.Exists(gamePath)) + { + continue; + } + + var entry = new DeploymentManifestEntry + { + RelativePath = relativePath, + SourceHash = await ComputeFileHashAsync(gamePath, cancellationToken).ConfigureAwait(false), + Size = new FileInfo(gamePath).Length, + DeploymentMethod = DeploymentMethod.Copy, + OverwroteExisting = false, + }; + + manifest.Entries.Add(entry); + _ = existingPaths.Add(relativePath); + recorded++; + } + + if (recorded > 0 || !TryGetManifest(componentGuid, out _)) + { + await SaveManifestAtomicAsync(manifest, cancellationToken).ConfigureAwait(false); + } + + await Logger.LogAsync( + $"[Deployment] Recorded {recorded} live game file(s) for component '{componentName}' ({componentGuid}) " + + $"(patcher/provenance; manifest now has {manifest.Entries.Count} entries).").ConfigureAwait(false); + + return manifest; + } + + /// + /// Builds a relative-path → SHA-256 index of files under the game directory + /// for patcher provenance diffs. Skips empty trees. + /// + [NotNull] + public async Task> CaptureGameFileHashIndexAsync( + CancellationToken cancellationToken = default) + { + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(_gameDirectory)) + { + return index; + } + + foreach (string fullPath in Directory.EnumerateFiles(_gameDirectory, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + string relative = NormalizeRelativePath( + NetFrameworkCompatibility.GetRelativePath(_gameDirectory, fullPath)); + if (string.IsNullOrWhiteSpace(relative)) + { + continue; + } + + index[relative] = await ComputeFileHashAsync(fullPath, cancellationToken).ConfigureAwait(false); + } + + return index; + } + + /// + /// Returns relative paths that were added or whose content hash changed versus + /// . + /// + [NotNull] + [ItemNotNull] + public async Task> DiffGameFileHashIndexAsync( + [NotNull] IReadOnlyDictionary beforeIndex, + CancellationToken cancellationToken = default) + { + if (beforeIndex is null) + { + throw new ArgumentNullException(nameof(beforeIndex)); + } + + Dictionary after = await CaptureGameFileHashIndexAsync(cancellationToken).ConfigureAwait(false); + var changed = new List(); + foreach (KeyValuePair kvp in after) + { + if (!beforeIndex.TryGetValue(kvp.Key, out string beforeHash) + || !string.Equals(beforeHash, kvp.Value, StringComparison.OrdinalIgnoreCase)) + { + changed.Add(kvp.Key); + } + } + + return changed; + } + /// /// Removes exactly the files listed in the component's manifest. Files whose /// current content hash no longer matches the manifest hash (modified by the diff --git a/src/ModSync.Core/Services/Installation/ManagedInstallResult.cs b/src/ModSync.Core/Services/Installation/ManagedInstallResult.cs index 26c50574..0b5dd8fc 100644 --- a/src/ModSync.Core/Services/Installation/ManagedInstallResult.cs +++ b/src/ModSync.Core/Services/Installation/ManagedInstallResult.cs @@ -24,5 +24,10 @@ public sealed class ManagedInstallResult public string ActiveProfileName { get; set; } public bool HasPatcherComponents => PatcherComponentNames.Count > 0; + + /// + /// True when at least one patcher's live game-dir writes were merged into a deployment manifest. + /// + public bool PatcherProvenanceRecorded { get; set; } } } diff --git a/src/ModSync.Core/Services/Installation/ManagedInstallSession.cs b/src/ModSync.Core/Services/Installation/ManagedInstallSession.cs index 518ef4d5..2848ab30 100644 --- a/src/ModSync.Core/Services/Installation/ManagedInstallSession.cs +++ b/src/ModSync.Core/Services/Installation/ManagedInstallSession.cs @@ -42,6 +42,12 @@ public sealed class ManagedInstallSession [NotNull] private readonly HashSet _patcherComponentNames = new HashSet(StringComparer.OrdinalIgnoreCase); + [NotNull] + private readonly HashSet _patcherComponentGuids = new HashSet(); + + [NotNull] + private readonly HashSet _patcherProvenanceRecordedGuids = new HashSet(); + public ManagedInstallSession( [NotNull] string profileName, [NotNull] IInstallBackend installBackend, @@ -184,6 +190,107 @@ public void RecordPatcherComponent([NotNull] string componentName) } } + public void RecordPatcherComponent(Guid componentGuid, [CanBeNull] string componentName) + { + _ = _patcherComponentGuids.Add(componentGuid); + RecordPatcherComponent(componentName ?? string.Empty); + } + + public bool ComponentUsedPatcher(Guid componentGuid) => _patcherComponentGuids.Contains(componentGuid); + + /// + /// Snapshot of game-directory file hashes before a component's instructions run + /// (used to detect HoloPatcher live writes). + /// + [ItemNotNull] + public Task> CaptureGameFileHashIndexAsync( + CancellationToken cancellationToken = default) + { + if (!(_installBackend is ManagedDeploymentInstallBackend managed)) + { + return Task.FromResult(new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + return managed.DeploymentService.CaptureGameFileHashIndexAsync(cancellationToken); + } + + /// + /// After staged deploy, records patcher-touched game files into the component + /// manifest when this component ran a Patcher instruction. + /// + [ItemNotNull] + public async Task RecordPatcherProvenanceAsync( + [NotNull] ModComponent component, + [CanBeNull] IReadOnlyDictionary preInstallFileIndex, + CancellationToken cancellationToken = default) + { + if (component is null) + { + throw new ArgumentNullException(nameof(component)); + } + + if (!ComponentUsedPatcher(component.Guid)) + { + return; + } + + if (!(_installBackend is ManagedDeploymentInstallBackend managedBackend)) + { + return; + } + + if (preInstallFileIndex is null) + { + await Logger.LogWarningAsync( + $"[ManagedInstall] Patcher ran for '{component.Name}' but no pre-install file index was captured; " + + "skipping live provenance recording.").ConfigureAwait(false); + return; + } + + bool hadManifestBefore = managedBackend.DeploymentService.TryGetManifest(component.Guid, out _); + + List changed = await managedBackend.DeploymentService + .DiffGameFileHashIndexAsync(preInstallFileIndex, cancellationToken) + .ConfigureAwait(false); + + // Exclude paths already covered by staged deploy for this component. + if (managedBackend.DeploymentService.TryGetManifest(component.Guid, out DeploymentManifest existing) + && existing?.Entries != null) + { + var already = new HashSet( + existing.Entries + .Where(e => !string.IsNullOrWhiteSpace(e.RelativePath)) + .Select(e => e.RelativePath), + StringComparer.OrdinalIgnoreCase); + changed = changed.Where(p => !already.Contains(p)).ToList(); + } + + if (changed.Count == 0) + { + await Logger.LogAsync( + $"[ManagedInstall] Patcher ran for '{component.Name}' but no new/changed game files were detected for provenance." + ).ConfigureAwait(false); + return; + } + + DeploymentManifest manifest = await managedBackend.DeploymentService + .RecordLiveGameFilesAsync(component.Guid, component.Name, changed, cancellationToken) + .ConfigureAwait(false); + + if (manifest != null && manifest.Entries.Count > 0) + { + _ = _patcherProvenanceRecordedGuids.Add(component.Guid); + if (!hadManifestBefore) + { + ManifestsWritten++; + } + + await Logger.LogAsync( + $"[ManagedInstall] Recorded patcher provenance for '{component.Name}': {changed.Count} file(s)." + ).ConfigureAwait(false); + } + } + public void ApplyStagingRedirect([NotNull] Instruction instruction, Guid componentGuid) { if (instruction is null) @@ -193,7 +300,7 @@ public void ApplyStagingRedirect([NotNull] Instruction instruction, Guid compone if (instruction.Action == Instruction.ActionType.Patcher) { - RecordPatcherComponent(instruction.GetParentComponent()?.Name ?? string.Empty); + RecordPatcherComponent(componentGuid, instruction.GetParentComponent()?.Name); return; } @@ -345,8 +452,18 @@ public void CopyPatcherNamesTo([NotNull] ManagedInstallResult result) { foreach (string name in _patcherComponentNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)) { + // Still warn only when we never recorded live provenance for that name's guid(s). result.PatcherComponentNames.Add(name); } + + result.PatcherProvenanceRecorded = _patcherProvenanceRecordedGuids.Count > 0; + // Drop names for guids that were successfully recorded — match by scanning components is hard; + // instead clear the warning list when every patcher guid was recorded. + if (_patcherComponentGuids.Count > 0 + && _patcherComponentGuids.All(g => _patcherProvenanceRecordedGuids.Contains(g))) + { + result.PatcherComponentNames.Clear(); + } } [NotNull] diff --git a/src/ModSync.Core/Services/Installation/ManagedInstallSummaryFormatter.cs b/src/ModSync.Core/Services/Installation/ManagedInstallSummaryFormatter.cs index ae3efa5a..2beec047 100644 --- a/src/ModSync.Core/Services/Installation/ManagedInstallSummaryFormatter.cs +++ b/src/ModSync.Core/Services/Installation/ManagedInstallSummaryFormatter.cs @@ -24,12 +24,16 @@ public static string FormatWizardSummary([CanBeNull] ManagedInstallResult result builder.AppendLine("Managed deployment recorded manifests for file-operation mods installed in this session."); builder.AppendLine($"Profile: {result.ActiveProfileName}"); builder.AppendLine($"Manifests written: {result.ManifestsWritten}"); - builder.AppendLine("Manifests are not yet actionable for uninstall in this release."); + + if (result.PatcherProvenanceRecorded) + { + builder.AppendLine("Patcher outputs were recorded into deployment manifests for uninstall."); + } if (result.HasPatcherComponents) { builder.AppendLine(); - builder.AppendLine("Warning: Patcher-modified files are not tracked for managed uninstall:"); + builder.AppendLine("Warning: Some patcher-touched mods have no recorded live file provenance:"); foreach (string name in result.PatcherComponentNames) { builder.AppendLine($"• {name}"); diff --git a/src/ModSync.Tests/DeploymentServiceTests.cs b/src/ModSync.Tests/DeploymentServiceTests.cs index af9d02e9..a3e8ffd4 100644 --- a/src/ModSync.Tests/DeploymentServiceTests.cs +++ b/src/ModSync.Tests/DeploymentServiceTests.cs @@ -3,6 +3,7 @@ // See LICENSE.txt file in the project root for full license information. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -327,5 +328,47 @@ public async Task Uninstall_SkipsPathTraversalRelativePaths() Assert.That(File.Exists(outsideFile), Is.True, "Path-traversal target must not be deleted"); Assert.That(File.Exists(GamePath("Override/safe.txt")), Is.False); } + + [Test] + public async Task RecordLiveGameFiles_ThenUninstall_RemovesPatcherWrittenFile() + { + Guid guid = Guid.NewGuid(); + Dictionary before = await _service.CaptureGameFileHashIndexAsync(); + + Directory.CreateDirectory(Path.Combine(_gameDirectory, "Override")); + File.WriteAllText(GamePath("Override/patcher_out.mdl"), "holopatcher bytes"); + + List changed = await _service.DiffGameFileHashIndexAsync(before); + Assert.That(changed, Does.Contain("Override/patcher_out.mdl")); + + DeploymentManifest manifest = await _service.RecordLiveGameFilesAsync( + guid, + "Patcher Mod", + changed); + + Assert.That(manifest.Entries.Any(e => e.RelativePath == "Override/patcher_out.mdl"), Is.True); + Assert.That(File.Exists(GamePath("Override/patcher_out.mdl")), Is.True); + + bool uninstalled = await _service.UninstallComponentAsync(guid); + Assert.That(uninstalled, Is.True); + Assert.That(File.Exists(GamePath("Override/patcher_out.mdl")), Is.False); + } + + [Test] + public async Task RecordLiveGameFiles_MergesWithExistingStagedManifest() + { + Guid guid = Guid.NewGuid(); + string staged = CreateStagedFile("comp", "Override/staged.txt", "staged"); + await _service.DeployComponentAsync(guid, "Hybrid", staged); + + File.WriteAllText(GamePath("Override/live.txt"), "live patcher"); + DeploymentManifest merged = await _service.RecordLiveGameFilesAsync( + guid, + "Hybrid", + new[] { "Override/live.txt" }); + + Assert.That(merged.Entries.Select(e => e.RelativePath), Does.Contain("Override/staged.txt")); + Assert.That(merged.Entries.Select(e => e.RelativePath), Does.Contain("Override/live.txt")); + } } }