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
12 changes: 10 additions & 2 deletions docs/knowledgebase/managed-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
16 changes: 15 additions & 1 deletion src/ModSync.Core/ModComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,15 @@ public async Task<InstallExitCode> InstallAsync(

try
{
ManagedInstallSession managedSession = ManagedInstallSession.Current;
IReadOnlyDictionary<string, string> preInstallIndex = null;
if (managedSession != null)
{
preInstallIndex = await managedSession
.CaptureGameFileHashIndexAsync(cancellationToken)
.ConfigureAwait(false);
}

var realFileSystem = new Services.FileSystem.RealFileSystemProvider();
InstallExitCode exitCode = await ExecuteInstructionsAsync(
Instructions,
Expand All @@ -855,10 +864,15 @@ public async Task<InstallExitCode> 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);
}
}
}

Expand Down
147 changes: 147 additions & 0 deletions src/ModSync.Core/Services/Deployment/DeploymentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,153 @@ await Logger.LogAsync(
return manifest;
}

/// <summary>
/// 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.
/// </summary>
[ItemNotNull]
public async Task<DeploymentManifest> RecordLiveGameFilesAsync(
Guid componentGuid,
[CanBeNull] string componentName,
[NotNull] IEnumerable<string> 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<string>(
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;
}

/// <summary>
/// Builds a relative-path → SHA-256 index of files under the game directory
/// for patcher provenance diffs. Skips empty trees.
/// </summary>
[NotNull]
public async Task<Dictionary<string, string>> CaptureGameFileHashIndexAsync(
CancellationToken cancellationToken = default)
{
var index = new Dictionary<string, string>(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;
}

/// <summary>
/// Returns relative paths that were added or whose content hash changed versus
/// <paramref name="beforeIndex"/>.
/// </summary>
[NotNull]
[ItemNotNull]
public async Task<List<string>> DiffGameFileHashIndexAsync(
[NotNull] IReadOnlyDictionary<string, string> beforeIndex,
CancellationToken cancellationToken = default)
{
if (beforeIndex is null)
{
throw new ArgumentNullException(nameof(beforeIndex));
}

Dictionary<string, string> after = await CaptureGameFileHashIndexAsync(cancellationToken).ConfigureAwait(false);
var changed = new List<string>();
foreach (KeyValuePair<string, string> kvp in after)
{
if (!beforeIndex.TryGetValue(kvp.Key, out string beforeHash)
|| !string.Equals(beforeHash, kvp.Value, StringComparison.OrdinalIgnoreCase))
{
changed.Add(kvp.Key);
}
}

return changed;
}

/// <summary>
/// Removes exactly the files listed in the component's manifest. Files whose
/// current content hash no longer matches the manifest hash (modified by the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,10 @@ public sealed class ManagedInstallResult
public string ActiveProfileName { get; set; }

public bool HasPatcherComponents => PatcherComponentNames.Count > 0;

/// <summary>
/// True when at least one patcher's live game-dir writes were merged into a deployment manifest.
/// </summary>
public bool PatcherProvenanceRecorded { get; set; }
}
}
119 changes: 118 additions & 1 deletion src/ModSync.Core/Services/Installation/ManagedInstallSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ public sealed class ManagedInstallSession
[NotNull]
private readonly HashSet<string> _patcherComponentNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

[NotNull]
private readonly HashSet<Guid> _patcherComponentGuids = new HashSet<Guid>();

[NotNull]
private readonly HashSet<Guid> _patcherProvenanceRecordedGuids = new HashSet<Guid>();

public ManagedInstallSession(
[NotNull] string profileName,
[NotNull] IInstallBackend installBackend,
Expand Down Expand Up @@ -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);

/// <summary>
/// Snapshot of game-directory file hashes before a component's instructions run
/// (used to detect HoloPatcher live writes).
/// </summary>
[ItemNotNull]
public Task<Dictionary<string, string>> CaptureGameFileHashIndexAsync(
CancellationToken cancellationToken = default)
{
if (!(_installBackend is ManagedDeploymentInstallBackend managed))
{
return Task.FromResult(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
}

return managed.DeploymentService.CaptureGameFileHashIndexAsync(cancellationToken);
}

/// <summary>
/// After staged deploy, records patcher-touched game files into the component
/// manifest when this component ran a Patcher instruction.
/// </summary>
[ItemNotNull]
public async Task RecordPatcherProvenanceAsync(
[NotNull] ModComponent component,
[CanBeNull] IReadOnlyDictionary<string, string> 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<string> 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<string>(
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)
Expand All @@ -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;
}

Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading