Harden installer activation against ACC file locks

This commit is contained in:
2026-08-11 15:37:52 +02:00
parent b32cc61e43
commit 08626197be
15 changed files with 389 additions and 23 deletions
@@ -23,7 +23,12 @@ namespace BizTalkPlatformManagementTool.Setup
private const string ProductName = "BizTalk Platform Management Tool";
/// <summary>Aktuelle Produktversion des Installers und Uninstall-Eintrags.</summary>
private const string ProductVersion = "2.1.2";
private const string ProductVersion = "2.1.3";
/// <summary>
/// Wartezeiten zwischen Wiederholungen atomarer Verzeichnisverschiebungen.
/// </summary>
private static readonly int[] DirectoryMoveRetryDelaysMilliseconds = { 250, 500, 1000, 2000, 3000, 5000, 8000 };
/// <summary>Maschinenweiter Registrypfad des Windows-Uninstall-Eintrags.</summary>
private const string UninstallKeyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\BizTalkPlatformManagementTool";
@@ -43,6 +48,12 @@ namespace BizTalkPlatformManagementTool.Setup
/// <summary>Optional injizierte Self-Test-Funktion für portable Regressionstests.</summary>
private readonly Func<string, bool> selfTestRunner;
/// <summary>Verschiebt Verzeichnisse produktiv oder über eine injizierte Regressionstest-Funktion.</summary>
private readonly Action<string, string> directoryMover;
/// <summary>Wartet zwischen Move-Versuchen produktiv oder über eine injizierte Regressionstest-Funktion.</summary>
private readonly Action<int> retryDelay;
/// <summary>Zuletzt tatsächlich verwendetes primäres oder temporäres Diagnoseverzeichnis.</summary>
private string lastInstallerLogDirectory;
@@ -90,12 +101,36 @@ namespace BizTalkPlatformManagementTool.Setup
/// <param name="registerWindowsIntegration"><c>true</c>, wenn Registry und Verknüpfungen verwaltet werden sollen.</param>
/// <param name="selfTestRunner">Optionale Testfunktion für Regressionstests; <c>null</c> startet die reale EXE.</param>
internal InstallerEngine(string packageDirectory, string installDirectory, string dataDirectory, bool registerWindowsIntegration, Func<string, bool> selfTestRunner)
: this(packageDirectory, installDirectory, dataDirectory, registerWindowsIntegration, selfTestRunner, Directory.Move, System.Threading.Thread.Sleep)
{
}
/// <summary>
/// Initialisiert den Installer mit zusätzlich austauschbarer Move- und Wartefunktion.
/// </summary>
/// <param name="packageDirectory">Das Verzeichnis mit Setup-Payload und Manifest.</param>
/// <param name="installDirectory">Das aktive Programmverzeichnis.</param>
/// <param name="dataDirectory">Das dauerhafte Daten- und Diagnoseverzeichnis.</param>
/// <param name="registerWindowsIntegration"><c>true</c>, wenn Registry und Verknüpfungen verwaltet werden sollen.</param>
/// <param name="selfTestRunner">Optionale Testfunktion für Regressionstests; <c>null</c> startet die reale EXE.</param>
/// <param name="directoryMover">Austauschbare atomare Verzeichnisverschiebung.</param>
/// <param name="retryDelay">Austauschbare Wartefunktion zwischen Wiederholungen.</param>
internal InstallerEngine(
string packageDirectory,
string installDirectory,
string dataDirectory,
bool registerWindowsIntegration,
Func<string, bool> selfTestRunner,
Action<string, string> directoryMover,
Action<int> retryDelay)
{
this.packageDirectory = Path.GetFullPath(packageDirectory);
this.installDirectory = Path.GetFullPath(installDirectory);
this.dataDirectory = Path.GetFullPath(dataDirectory);
this.registerWindowsIntegration = registerWindowsIntegration;
this.selfTestRunner = selfTestRunner;
this.directoryMover = directoryMover ?? throw new ArgumentNullException("directoryMover");
this.retryDelay = retryDelay ?? throw new ArgumentNullException("retryDelay");
}
/// <summary>Ruft das feste maschinenweite Programmverzeichnis ab.</summary>
@@ -138,6 +173,7 @@ namespace BizTalkPlatformManagementTool.Setup
WindowsIntegrationSnapshot integrationSnapshot = null;
var backupCreated = false;
var activated = false;
var activationUsedCopyFallback = false;
var integrationMutationStarted = false;
var phaseCode = "SETUP-INITIALIZATION";
var phase = "Initialisierung";
@@ -195,14 +231,34 @@ namespace BizTalkPlatformManagementTool.Setup
if (hadExistingInstallation)
{
log.Write("INFO", "event=directory_move role=backup source=\"" + installDirectory + "\" target=\"" + backupDirectory + "\"");
Directory.Move(installDirectory, backupDirectory);
MoveDirectoryWithRetry(installDirectory, backupDirectory, "backup", log);
backupCreated = true;
log.Write("INFO", "event=directory_move_complete role=backup");
}
log.Write("INFO", "event=directory_move role=activate source=\"" + stagingDirectory + "\" target=\"" + installDirectory + "\"");
Directory.Move(stagingDirectory, installDirectory);
activated = true;
log.Write("INFO", "event=directory_move_complete role=activate");
try
{
MoveDirectoryWithRetry(stagingDirectory, installDirectory, "activate", log);
activated = true;
log.Write("INFO", "event=directory_move_complete role=activate method=atomic_move");
}
catch (IOException ex)
{
if (hadExistingInstallation || Directory.Exists(installDirectory)) throw;
// Eine Neuinstallation hat noch keine aktive Vorversion. Deshalb ist ein
// erneut hashgeprüfter Copy-Fallback sicher rückrollbar, ohne die strengere
// atomare Update-Grenze für bestehende Installationen aufzuweichen.
activated = true;
ActivateNewInstallationByVerifiedCopy(stagingDirectory, installDirectory, files, ex, log);
activationUsedCopyFallback = true;
}
catch (UnauthorizedAccessException ex)
{
if (hadExistingInstallation || Directory.Exists(installDirectory)) throw;
activated = true;
ActivateNewInstallationByVerifiedCopy(stagingDirectory, installDirectory, files, ex, log);
activationUsedCopyFallback = true;
}
phaseCode = "SETUP-ACTIVATED-SELFTEST";
phase = "Aktivierte Version erneut pruefen";
@@ -230,10 +286,14 @@ namespace BizTalkPlatformManagementTool.Setup
phase = "Backup bereinigen";
write("Phase 6/6: " + phase + ".");
var backupCleanupSucceeded = TryDeleteDirectory(backupDirectory, write);
var stagingCleanupSucceeded = TryDeleteDirectory(stagingDirectory, write);
var cleanupSucceeded = backupCleanupSucceeded && stagingCleanupSucceeded;
log.Write(
backupCleanupSucceeded ? "INFO" : "WARN",
"event=setup_completed result=" + (backupCleanupSucceeded ? "success" : "success_with_backup_cleanup_warning")
cleanupSucceeded ? "INFO" : "WARN",
"event=setup_completed result=" + (cleanupSucceeded ? "success" : "success_with_temporary_cleanup_warning")
+ " backup_directory_preserved=" + Directory.Exists(backupDirectory)
+ " staging_directory_preserved=" + Directory.Exists(stagingDirectory)
+ " activation_method=" + (activationUsedCopyFallback ? "verified_copy_fallback" : "atomic_move")
+ " install_directory=\"" + installDirectory + "\"");
write("Installation/Update erfolgreich abgeschlossen: " + installDirectory);
write("Technische Installationsabnahme erfolgreich. BizTalk-WMI wurde bewusst nicht als Installerkriterium verwendet; bitte anschliessend in der Anwendung 'Diagnose' und einen Dry-run ausfuehren.");
@@ -264,7 +324,7 @@ namespace BizTalkPlatformManagementTool.Setup
if (backupCreated && Directory.Exists(backupDirectory))
{
log.Write("WARN", "event=rollback_step step=restore_backup source=\"" + backupDirectory + "\" target=\"" + installDirectory + "\"");
Directory.Move(backupDirectory, installDirectory);
MoveDirectoryWithRetry(backupDirectory, installDirectory, "rollback_restore_backup", log);
}
write(backupCreated ? "Rollback: vorherige Programmversion wiederhergestellt." : "Rollback: unvollstaendige Neuinstallation entfernt.");
log.Write("WARN", "event=rollback_step_complete step=files result=success");
@@ -353,7 +413,7 @@ namespace BizTalkPlatformManagementTool.Setup
if (Directory.Exists(installDirectory))
{
log.Write("INFO", "event=directory_move role=uninstall_quarantine source=\"" + installDirectory + "\" target=\"" + removalDirectory + "\"");
Directory.Move(installDirectory, removalDirectory);
MoveDirectoryWithRetry(installDirectory, removalDirectory, "uninstall_quarantine", log);
filesMoved = true;
log.Write("INFO", "event=directory_move_complete role=uninstall_quarantine");
}
@@ -375,7 +435,7 @@ namespace BizTalkPlatformManagementTool.Setup
{
try
{
Directory.Move(removalDirectory, installDirectory);
MoveDirectoryWithRetry(removalDirectory, installDirectory, "uninstall_rollback_restore", log);
log.Write("WARN", "event=uninstall_rollback_step step=restore_program_directory result=success");
}
catch (Exception rollbackException)
@@ -838,6 +898,92 @@ namespace BizTalkPlatformManagementTool.Setup
}
}
/// <summary>
/// Aktiviert eine Neuinstallation durch eine erneut hashgeprüfte Kopie, wenn Windows
/// ausschließlich die Verzeichnisumbenennung des erfolgreich geprüften Stagings sperrt.
/// </summary>
/// <param name="stagingDirectory">Das bereits validierte Staging-Verzeichnis.</param>
/// <param name="targetDirectory">Das noch nicht vorhandene Installationsverzeichnis.</param>
/// <param name="files">Die validierten Manifesteinträge.</param>
/// <param name="moveException">Der vorausgegangene dauerhafte Move-Fehler.</param>
/// <param name="log">Das Diagnoseprotokoll des aktuellen Setup-Laufs.</param>
private static void ActivateNewInstallationByVerifiedCopy(
string stagingDirectory,
string targetDirectory,
IEnumerable<PackageFile> files,
Exception moveException,
SetupOperationLog log)
{
log.Write(
"WARN",
"event=activation_fallback_started method=verified_copy reason=directory_move_exhausted "
+ SetupOperationLog.FormatException(moveException));
CopyPayload(stagingDirectory, targetDirectory, files);
log.Write("INFO", "event=activation_fallback_complete method=verified_copy target=\"" + targetDirectory + "\"");
}
/// <summary>
/// Wiederholt eine atomare Verzeichnisverschiebung bei kurzzeitigen Windows-Dateisperren.
/// </summary>
/// <param name="source">Das vorhandene Quellverzeichnis.</param>
/// <param name="target">Das noch nicht vorhandene Zielverzeichnis.</param>
/// <param name="role">Die maschinenlesbare Rolle für das Diagnoselog.</param>
/// <param name="log">Das Diagnoseprotokoll des aktuellen Setup-Laufs.</param>
private void MoveDirectoryWithRetry(string source, string target, string role, SetupOperationLog log)
{
var maximumAttempts = DirectoryMoveRetryDelaysMilliseconds.Length + 1;
for (var attempt = 1; ; attempt++)
{
try
{
directoryMover(source, target);
if (attempt > 1)
{
log.Write(
"INFO",
"event=directory_move_recovered role=" + role
+ " attempt=" + attempt.ToString(CultureInfo.InvariantCulture)
+ " max_attempts=" + maximumAttempts.ToString(CultureInfo.InvariantCulture));
}
return;
}
catch (IOException ex)
{
if (attempt >= maximumAttempts) throw;
WaitBeforeDirectoryMoveRetry(role, attempt, maximumAttempts, ex, log);
}
catch (UnauthorizedAccessException ex)
{
if (attempt >= maximumAttempts) throw;
WaitBeforeDirectoryMoveRetry(role, attempt, maximumAttempts, ex, log);
}
}
}
/// <summary>
/// Protokolliert und wartet vor einer weiteren Verzeichnisverschiebung.
/// </summary>
/// <param name="role">Die maschinenlesbare Rolle der Verschiebung.</param>
/// <param name="failedAttempt">Die Nummer des fehlgeschlagenen Versuchs.</param>
/// <param name="maximumAttempts">Die maximale Gesamtzahl der Versuche.</param>
/// <param name="exception">Der als vorübergehend behandelte Dateisystemfehler.</param>
/// <param name="log">Das Diagnoseprotokoll des aktuellen Setup-Laufs.</param>
private void WaitBeforeDirectoryMoveRetry(string role, int failedAttempt, int maximumAttempts, Exception exception, SetupOperationLog log)
{
var delayMilliseconds = DirectoryMoveRetryDelaysMilliseconds[failedAttempt - 1];
// Direkt nach einem EXE-Self-Test können Loader, Virenscanner oder EDR das
// Delete-/Rename-Handle noch kurz halten. Begrenztes Backoff erhält die atomare
// Aktivierung; dauerhafte ACL-Fehler werden nach maximal 19,75 Sekunden sichtbar.
log.Write(
"WARN",
"event=directory_move_retry role=" + role
+ " failed_attempt=" + failedAttempt.ToString(CultureInfo.InvariantCulture)
+ " max_attempts=" + maximumAttempts.ToString(CultureInfo.InvariantCulture)
+ " delay_ms=" + delayMilliseconds.ToString(CultureInfo.InvariantCulture)
+ " " + SetupOperationLog.FormatException(exception));
retryDelay(delayMilliseconds);
}
/// <summary>
/// Stellt sicher, dass eine betriebsnotwendige Datei im Manifest enthalten ist.
/// </summary>
@@ -65,7 +65,7 @@ namespace BizTalkPlatformManagementTool.Setup
{
AutoSize = true,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
Text = "BizTalk Platform Management Tool 2.1.2"
Text = "BizTalk Platform Management Tool 2.1.3"
});
root.Controls.Add(new Label
{
@@ -8,6 +8,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: ComVisible(false)]
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
[assembly: AssemblyVersion("2.1.2.0")]
[assembly: AssemblyFileVersion("2.1.2.0")]
[assembly: AssemblyVersion("2.1.3.0")]
[assembly: AssemblyFileVersion("2.1.3.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.1.2.0" name="BizTalkPlatformManagementTool.Setup" />
<assemblyIdentity version="2.1.3.0" name="BizTalkPlatformManagementTool.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
</trustInfo>
@@ -8,5 +8,5 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCopyright("Copyright © 2026")]
[assembly: ComVisible(false)]
[assembly: Guid("2c5b2c0a-f407-46c2-9e3b-1fa09fa8445a")]
[assembly: AssemblyVersion("2.1.2.0")]
[assembly: AssemblyFileVersion("2.1.2.0")]
[assembly: AssemblyVersion("2.1.3.0")]
[assembly: AssemblyFileVersion("2.1.3.0")]
@@ -15,7 +15,7 @@ namespace BizTalkPlatformManagementTool.Services
/// <summary>
/// Current tool version written into generated snapshots.
/// </summary>
public const string Version = "2.1.2-net461";
public const string Version = "2.1.3-net461";
/// <summary>
/// Fallback application name used when WMI does not expose an application property.
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.1.2.0" name="BizTalkPlatformManagementTool" />
<assemblyIdentity version="2.1.3.0" name="BizTalkPlatformManagementTool" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>