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
+43
View File
@@ -0,0 +1,43 @@
# AI-Maintainer-Handoff
## Projekt und Sicherheitsziel
Das Repository enthält ein .NET-Framework-4.6.1-WinForms-Tool für kontrollierte BizTalk-2020-Wartungsoperationen. Änderungen müssen Dry-run, explizite Freigabe realer Aktionen, sichere Reihenfolgen und wiederherstellbare Installergrenzen erhalten. Die aktuelle Produktversion ist 2.1.3.
## Installerinvarianten
- Vor der ersten Mutation müssen Manifest, Länge, SHA-256 und Staging-Self-Test erfolgreich sein.
- Eine laufende installierte Anwendung blockiert Update und Deinstallation.
- Ein Update darf die aktive Vorversion erst nach erfolgreichem Staging verändern.
- Das Backup einer bestehenden Installation wird nur atomar per `Directory.Move` erzeugt. Bei dauerhaftem Fehler bleibt die Vorversion aktiv; es gibt keinen In-place-Kopierfallback.
- Nur eine Neuinstallation ohne bestehendes Ziel darf nach ausgeschöpften Move-Retries eine erneut SHA-256-geprüfte Kopie aktivieren.
- Nach jeder Aktivierungsart läuft der Self-Test erneut aus dem endgültigen Installationsziel.
- Sobald ein Zielverzeichnis teilweise angelegt sein kann, muss `activated=true` gesetzt sein, damit der Catch-Pfad es entfernt.
- Windows-Integration wird erst nach bestandenem Ziel-Self-Test verändert und bei Folgefehlern aus dem Snapshot restauriert.
- Diagnose-Logging darf das eigentliche Setup-Ergebnis nie ersetzen.
Die zentrale Implementierung liegt in `src/BizTalkPlatformManagementTool.Setup/InstallerEngine.cs`. Move-Retries sind auf acht Versuche und 19,75 Sekunden Wartezeit begrenzt. Die injizierbaren `directoryMover`- und `retryDelay`-Delegates existieren ausschließlich, damit Sperrpfade ohne echte Wartezeit portabel getestet werden können.
## Versionierung
Bei einem Release sind mindestens diese Stellen konsistent zu ändern:
- `InstallerEngine.ProductVersion`
- Setup-Titel in `MainForm`
- beide `Properties/AssemblyInfo.cs`
- beide `app.manifest`
- `BizTalkOperationService.Version`
- `CHANGELOG.md`
## Verifikation
Portable Befehle:
```sh
msbuild BizTalkPlatformManagementTool.sln /t:Rebuild /p:Configuration=Release '/p:Platform=Any CPU' /m:1
mono tests/BizTalkPlatformManagementTool.Tests/bin/Release/BizTalkPlatformManagementTool.Tests.exe
mono src/BizTalkPlatformManagementTool/bin/Release/BizTalkPlatformManagementTool.exe --self-test
mono src/BizTalkPlatformManagementTool.Packager/bin/Release/BizTalkPlatformManagementTool.Packager.exe . Release
```
Nach der Paketierung müssen ZIP, Base64-TXT und SHA-256-Datei gegengeprüft werden. Die abschließende Freigabe braucht zusätzlich einen repräsentativen Windows-/ACC-Test von UAC, `Program Files`-ACL/EDR, Registry, Verknüpfungen, Update, Rollback und Deinstallation sowie BizTalk-Diagnose und Dry-run.
+12
View File
@@ -1,6 +1,18 @@
# Changelog
## [2.1.3] - 2026-08-11
### Added
- Bounded retry/backoff diagnostics for transient `Directory.Move` locks caused by the Windows loader, antivirus or endpoint protection.
- A SHA-256-verified copy activation fallback limited to first-time installations with no existing active version.
- Regression coverage for transient recovery, permanent new-install fallback and permanent update denial without mutation.
- ACC incident analysis and an AI maintainer handoff document.
### Changed
- Install, update, rollback and uninstall directory moves now use eight bounded attempts over at most 19.75 seconds.
- Setup completion logs record the activation method and preservation state of both backup and staging directories.
- Product and setup version advanced to 2.1.3.
## [2.1.2] - 2026-08-11
### Added
- Complete XML documentation for types and methods across application, setup, packager and regression projects, including parameters, generic type parameters and return values.
+6 -1
View File
@@ -128,7 +128,7 @@ Die Aktivierung ist transaktional aufgebaut:
3. Kopierte Dateien nochmals per SHA-256 und die Staging-EXE per `--self-test` prüfen.
4. Laufende Toolinstanz ausschließen.
5. Bestehendes Verzeichnis in ein eindeutiges Backup verschieben.
6. Staging auf demselben Volume als produktives Verzeichnis aktivieren.
6. Staging auf demselben Volume als produktives Verzeichnis aktivieren; kurzzeitige Rename-Sperren werden mit acht begrenzten Versuchen und insgesamt höchstens 19,75 Sekunden Backoff behandelt.
7. Aktivierte EXE erneut per `--self-test` prüfen.
8. Erst danach Startmenü, optionale Desktop-Verknüpfung und Windows-Uninstall-Eintrag schreiben.
9. Windows-Integration durch Rücklesen der Verknüpfungen und der wesentlichen Registrywerte verifizieren.
@@ -136,6 +136,8 @@ Die Aktivierung ist transaktional aufgebaut:
Fehler vor der ersten Mutation werden als **Kein Rollback erforderlich** ausgewiesen. Ein Rollback wird nur als erfolgreich bezeichnet, wenn seine einzelnen Schritte ohne Fehler abgeschlossen wurden; ein verbliebenes Backup ohne aktives Programmverzeichnis verletzt eine zusätzlich geprüfte Invariante und wird als unvollständiger Rollback gemeldet. Bereinigungsprobleme an einem nicht mehr benötigten Backup oder Deinstallations-Quarantäneverzeichnis werden als erfolgreiche Operation mit Warnung protokolliert und zerstören die wiederherstellbaren Dateien nicht blind.
Wenn ausschließlich die atomare Staging-Umbenennung einer Neuinstallation dauerhaft gesperrt bleibt, ist ein eng begrenzter Kopierfallback zulässig: Es existiert dabei keine aktive Vorversion, jede Zieldatei wird erneut anhand des Manifests per SHA-256 geprüft und der zweite Self-Test läuft weiterhin aus dem endgültigen Ziel. Updates verwenden diesen Fallback absichtlich nicht, weil die unveränderte alte Installation Vorrang vor einer nicht atomaren In-place-Aktualisierung hat. Jeder Retry und die verwendete Aktivierungsmethode stehen im Setup-Log.
Das dauerhafte Setup-Log enthält pro Lauf einen Kontextkopf mit Version, Betriebssystem, 32-/64-Bit-Kontext, CLR, Rechner, Identität und Elevation. Hinzu kommen Pfade, freier Speicher, Dateiversion/-größe/-SHA-256, vollständige Self-Test-Ausgaben mit Exitcode, Exception-Ketten mit HRESULT und Stacktrace sowie jeder Rollbackschritt. Stabile `SETUP-*`- und `UNINSTALL-*`-Fehlercodes ermöglichen die Zuordnung unabhängig vom lokalisierten Meldungstext. Die Aufbewahrung beträgt 90 Tage. Wenn ProgramData für die Diagnose nicht verfügbar ist, wird ein Temp-Fallback verwendet und sichtbar gemeldet; die Schaltfläche **Diagnoselogs öffnen** öffnet den regulären Ablageort.
Der Self-Test ist eine technische Installationsabnahme ohne BizTalk-WMI. Ein fachlicher Umgebungszustand wie fehlender WMI-Zugriff oder `Unknown` führt deshalb nicht zum Installer-Rollback. Nach erfolgreicher Installation bleiben **Diagnose** und ein Dry-run gegen das Zielsystem verpflichtende Abnahmeschritte.
@@ -154,6 +156,9 @@ Das äußere ZIP erhält zusätzlich eine SHA-256-Datei und eine Certutil-kompat
- Ablehnung nicht deklarierter Dateien und aus dem Payload-Verzeichnis ausbrechender Manifestpfade.
- Staging-Fehler ohne Mutation einer bestehenden Installation.
- Erfolgreiche Staging-Aktivierung.
- Erholung nach einer vorübergehenden Rename-Sperre mit korrektem Backoff und Diagnoseereignissen.
- Verifizierter Kopierfallback einer dauerhaft move-gesperrten Neuinstallation.
- Begrenzter Abbruch eines dauerhaft move-gesperrten Updates ohne Veränderung der Vorversion.
- Wiederherstellung der Vorversion, wenn der Self-Test nach Aktivierung fehlschlägt.
- Deinstallation durch atomare Umbenennung des Programmverzeichnisses vor der bestmöglichen Bereinigung.
- Eindeutige Fehlerphase ohne irreführende Rollbackmeldung bei einem Staging-Fehler.
+6 -2
View File
@@ -51,11 +51,13 @@ Der Installer verändert eine bestehende Installation erst nach erfolgreicher Pa
2. Die neue Version wird in ein eindeutiges Staging-Verzeichnis kopiert und dort mit `--self-test` geprüft.
3. Eine laufende Toolinstanz blockiert das Update.
4. Die bestehende Installation wird in ein Backup-Verzeichnis verschoben.
5. Das validierte Staging wird auf demselben Volume aktiviert.
5. Das validierte Staging wird auf demselben Volume aktiviert. Atomare Verzeichnisverschiebungen werden bei `IOException` oder `UnauthorizedAccessException` mit begrenztem Backoff bis zu achtmal versucht (insgesamt höchstens 19,75 Sekunden Wartezeit).
6. Die aktivierte EXE führt den Self-Test erneut aus.
7. Erst danach werden Verknüpfungen und Windows-Uninstall-Eintrag aktualisiert.
Schlägt ein Schritt nach Beginn der Umschaltung fehl, entfernt das Setup die neue Version und stellt das Backup wieder her. Tritt der Fehler bereits bei Paketprüfung oder Staging auf, meldet das Setup ausdrücklich **Kein Rollback erforderlich**, weil die aktive Installation unverändert blieb. Staging und Backup werden anschließend bestmöglich bereinigt. Kann ein nicht mehr benötigtes Backup wegen einer Dateisperre nicht gelöscht werden, bleibt die erfolgreiche Installation aktiv und das Log kennzeichnet den Abschluss als `success_with_backup_cleanup_warning`.
Bei einer Neuinstallation ohne bestehendes Ziel darf der Installer nach vollständig ausgeschöpften Move-Versuchen auf eine verifizierte Kopie zurückfallen. Dabei wird jede Datei aus dem bereits geprüften Staging nochmals gegen ihren Manifest-SHA-256 geprüft; anschließend läuft auch der zweite Self-Test aus dem endgültigen Ziel. Dieser Fallback gilt ausdrücklich nicht für Updates: Kann dort die Vorversion nicht atomar ins Backup verschoben werden, bricht das Update unverändert ab.
Schlägt ein Schritt nach Beginn der Umschaltung fehl, entfernt das Setup die neue Version und stellt das Backup wieder her. Tritt der Fehler bereits bei Paketprüfung oder Staging auf, meldet das Setup ausdrücklich **Kein Rollback erforderlich**, weil die aktive Installation unverändert blieb. Staging und Backup werden anschließend bestmöglich bereinigt. Kann ein nicht mehr benötigtes temporäres Verzeichnis wegen einer Dateisperre nicht gelöscht werden, bleibt die erfolgreiche Installation aktiv und das Log kennzeichnet den Abschluss als `success_with_temporary_cleanup_warning`.
## Installer-Diagnose
@@ -75,6 +77,8 @@ Die Abschlussmeldung enthält einen stabilen Fehlercode und die betroffene Phase
Das Log enthält Betriebssystem, Prozess-/OS-Bitness, .NET-Version, Rechner, Windows-Identität und Elevation sowie Installationspfade, freien Speicher, Dateigrößen, Versionen und SHA-256. Für Self-Tests werden Laufzeit, Exitcode dezimal und hexadezimal sowie vollständiges `stdout` und `stderr` geschrieben. Bei Fehlern folgen Exception-Kette, HRESULT und Stacktrace; jeder Rollbackschritt und sein Ergebnis werden separat protokolliert. Passwörter oder Verbindungsdaten werden vom Installer nicht verarbeitet oder geloggt.
Bei einer gesperrten Verzeichnisverschiebung erscheinen `event=directory_move_retry` und nach einer Erholung `event=directory_move_recovered`. Eine Neuinstallation mit Kopierfallback protokolliert `event=activation_fallback_started`, `event=activation_fallback_complete` und im Abschluss `activation_method=verified_copy_fallback`. Ein dauerhaft gesperrtes Update verwendet keinen Kopierfallback und endet weiterhin mit `SETUP-ACTIVATION`, bevor die aktive Version verändert wurde.
Für eine Supportanalyse bitte sichern:
1. Vollständige Fehlermeldung einschließlich Fehlercode und Phase.
+3
View File
@@ -26,6 +26,7 @@ WinForms tool for controlled Microsoft BizTalk Server 2020 platform operations d
- Clear and Close actions in the main toolbar
- No compile-time dependency on BizTalk ExplorerOM assemblies
- Transactional Windows installer/updater with SHA-256 payload validation and rollback
- Bounded activation retries plus a verified first-install copy fallback for ACC/EDR rename locks
- Installer diagnostics with stable phase codes, complete child-process output, exception chains and a ProgramData-to-Temp log fallback
- WMI-free runtime self-test plus automated regression test executable
@@ -102,4 +103,6 @@ Targeted German inline comments explain non-obvious operational decisions such a
- [Installation](Installation.md)
- [Dokumentation](Dokumentation.md)
- [Installer stability analysis](docs/Installer-Stabilitaetsanalyse-2026-08-11.md)
- [ACC activation incident analysis](docs/ACC-Installer-Aktivierungsfehler-2026-08-11.md)
- [AI maintainer handoff](AI-README.md)
- [References](REFERENCES.md)
@@ -0,0 +1,36 @@
# ACC-Installer-Aktivierungsfehler vom 11.08.2026
## Befund
Die Installation von Version 2.1.2 auf `AV23AGPWBI01` scheiterte in Phase 3 mit `SETUP-ACTIVATION`, `System.IO.IOException` und HRESULT `0x80070005`. Betroffen war die Verschiebung
`C:\Program Files\BizTalkPlatformManagementTool.staging.<guid>``C:\Program Files\BizTalkPlatformManagementTool`.
Das Log belegt gleichzeitig:
- Setup lief 64-Bit und erhöht (`elevated=true`).
- Das Ziel war eine Neuinstallation (`existing_installation=False`).
- Paketmanifest, Dateilängen und SHA-256 waren korrekt.
- Das Staging-Verzeichnis konnte unter `Program Files` angelegt und vollständig beschrieben werden.
- Die Staging-EXE startete und beendete ihren Self-Test erfolgreich.
- Zwischen dem protokollierten Self-Test-Ende und dem ersten `Directory.Move` lagen nur rund 15 ms.
Damit sind ein beschädigtes Paket, fehlender Speicherplatz, eine laufende Altversion und ein generelles Fehlen von Schreibrechten als unmittelbare Ursache nicht plausibel. `0x80070005` beweist jedoch nicht, welcher Prozess oder welche Policy die Verschiebung blockierte. Der wahrscheinlichste Befund ist ein noch kurz gehaltenes Delete-/Rename-Handle des Windows Loaders, Virenscanners oder ACC-Endpoint-Schutzes unmittelbar nach Ausführung der neuen EXE. Eine dauerhaft auf Rename beschränkte Endpoint-Policy bleibt als zweite Möglichkeit bestehen.
## Fix in Version 2.1.3
Alle transaktionalen Verzeichnisverschiebungen verwenden jetzt höchstens acht Versuche. Nach einem `IOException` oder `UnauthorizedAccessException` wartet das Setup 250, 500, 1.000, 2.000, 3.000, 5.000 und 8.000 ms. Die gesamte zusätzliche Wartezeit ist damit auf 19,75 Sekunden begrenzt. Jeder Fehlversuch und eine spätere Erholung werden mit Exceptiontyp, HRESULT, Rolle, Versuch und Wartezeit protokolliert.
Bleibt bei einer reinen Neuinstallation die Staging-Umbenennung dauerhaft gesperrt, kopiert das Setup die bereits validierte Payload in das noch nicht vorhandene Installationsziel. Jede Zieldatei wird dabei erneut gegen den Manifest-SHA-256 geprüft. Danach läuft der zweite Self-Test unverändert aus dem endgültigen Ziel. Ein Teilfehler gilt als begonnene Mutation und entfernt das unvollständige Ziel per Rollback.
Für Updates existiert dieser Kopierfallback absichtlich nicht. Lässt sich die aktive Version nicht atomar ins Backup verschieben, bleibt sie unangetastet und das Setup bricht nach den begrenzten Versuchen mit `SETUP-ACTIVATION` ab.
## Erwartete ACC-Abnahme
1. `BizTalkPlatformManagementTool-Setup.zip.b64.txt` dekodieren und den SHA-256 des ZIP prüfen.
2. In einen neuen Ordner entpacken und `Setup.exe` starten.
3. Im Erfolgslog `setup_version=2.1.3.0` und `activation_method=atomic_move` oder `activation_method=verified_copy_fallback` prüfen.
4. Anwendung starten, **Diagnose** ausführen und danach einen Dry-run erstellen.
5. Bei erneutem Fehler das vollständige neue `setup-*.log` sichern. Die `directory_move_retry`-Ereignisse zeigen dann, ob und wie lange ACC die Operation blockiert hat.
Die lokale Mono-Prüfung deckt Build, 17 Regressionstests, Anwendungsselftest, Paketmanifest und Transportartefakte ab. UAC, Endpoint-Schutz, Windows-Registry, Verknüpfungen und BizTalk-WMI müssen weiterhin auf ACC geprüft werden.
@@ -11,6 +11,8 @@ Vor Version 2.1.0 enthielt das Repository keinen Installer für die C#-Anwendung
- Keine Mutation vor vollständig bestandenem Manifest- und Staging-Self-Test.
- Update nur bei geschlossener produktiver Toolinstanz.
- Staging und Backup als eindeutige Geschwister des Installationsverzeichnisses auf demselben Volume.
- Acht begrenzte Move-Versuche mit 250, 500, 1.000, 2.000, 3.000, 5.000 und 8.000 ms Backoff zwischen den Versuchen.
- SHA-256-verifizierter Kopierfallback ausschließlich für eine Neuinstallation ohne aktive Vorversion; Updates bleiben strikt atomar.
- Zweiter Self-Test nach Aktivierung und vor Windows-Registrierung.
- Automatisches Datei- und Registrierungsrollback bei Fehlern.
- Dauerhaftes phasenbezogenes Installerlog unter ProgramData.
@@ -24,7 +26,7 @@ Vor Version 2.1.0 enthielt das Repository keinen Installer für die C#-Anwendung
## Lokal verifiziert
- Release-Build aller Projekte mit Mono MSBuild.
- Vierzehn Regressionstests einschließlich manipulierter/zusätzlicher/ausbrechender Payload-Pfade, Staging-Abbruch ohne Mutation, erzwungenem Fehler des zweiten Self-Tests mit Wiederherstellung der Vorversion, Deinstallation über ein Quarantäneverzeichnis, Diagnosekontext, Log-Fallback und Fehlerisolierung der UI-Ausgabe.
- Siebzehn Regressionstests einschließlich manipulierter/zusätzlicher/ausbrechender Payload-Pfade, Staging-Abbruch ohne Mutation, transienter Move-Erholung, verifiziertem Neuinstallationsfallback, begrenztem Updateabbruch, erzwungenem Fehler des zweiten Self-Tests mit Wiederherstellung der Vorversion, Deinstallation über ein Quarantäneverzeichnis, Diagnosekontext, Log-Fallback und Fehlerisolierung der UI-Ausgabe.
- WMI-freier Self-Test der produktiven EXE.
- Erstellung des Installationsordners, ZIPs, Base64-TXTs und der SHA-256-Datei.
- Rückdekodierung der Base64-TXT und Bytevergleich mit dem ZIP.
@@ -47,7 +49,7 @@ Die lokale Linux-/Mono-Verifikation kann folgende Windows-spezifische Punkte nic
2. Startmenü- und optionale Desktop-Verknüpfung über Windows Script Host.
3. 64-Bit-Uninstall-Eintrag und Aufruf über Apps & Features.
4. Updateblockade bei laufender installierter GUI.
5. Reales Rollback bei Dateisperren, Virenscannerzugriff oder Registryfehlern.
5. Reales Rollback bei nicht durch den neuen Retry/Fallback aufgelösten Dateisperren oder Registryfehlern.
6. ProgramData-Fehler und Temp-Log-Fallback unter realen Windows-ACLs.
7. Diagnose und Laufzeitoperationen gegen `root\MicrosoftBizTalkServer` auf BizTalk Server 2020.
@@ -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>
@@ -29,6 +29,9 @@ namespace BizTalkPlatformManagementTool.Tests
Run("PackageManifestRejectsTampering", PackageManifestRejectsTampering);
Run("PackageManifestRejectsUndeclaredAndTraversalFiles", PackageManifestRejectsUndeclaredAndTraversalFiles);
Run("InstallerActivatesValidatedPayload", InstallerActivatesValidatedPayload);
Run("InstallerRetriesTransientActivationMove", InstallerRetriesTransientActivationMove);
Run("InstallerUsesVerifiedCopyFallbackForNewInstall", InstallerUsesVerifiedCopyFallbackForNewInstall);
Run("InstallerStopsAfterBoundedUpdateMoveRetries", InstallerStopsAfterBoundedUpdateMoveRetries);
Run("InstallerDoesNotMutateOnStagingFailure", InstallerDoesNotMutateOnStagingFailure);
Run("InstallerRollsBackFailedActivatedSelfTest", InstallerRollsBackFailedActivatedSelfTest);
Run("InstallerUninstallRemovesProgramDirectory", InstallerUninstallRemovesProgramDirectory);
@@ -147,6 +150,118 @@ namespace BizTalkPlatformManagementTool.Tests
});
}
/// <summary>Prüft die erfolgreiche Aktivierung nach einer kurzzeitigen Rename-Sperre.</summary>
private static void InstallerRetriesTransientActivationMove()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
var data = Path.Combine(directory, "data");
var moveCalls = 0;
var delays = new List<int>();
var engine = new InstallerEngine(
package,
install,
data,
false,
path => true,
(source, target) =>
{
moveCalls++;
if (moveCalls == 1) throw new IOException("simulated transient scanner lock");
Directory.Move(source, target);
},
delays.Add);
engine.Install(false, null);
Assert(moveCalls == 2, "transient activation move was not retried exactly once");
Assert(delays.SequenceEqual(new[] { 250 }), "unexpected retry delay for transient activation move");
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "new", "payload was not activated after retry");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=directory_move_retry role=activate failed_attempt=1"), "transient move retry was not diagnosed");
Assert(log.Contains("event=directory_move_recovered role=activate attempt=2"), "move recovery was not diagnosed");
});
}
/// <summary>Prüft den verifizierten Kopierfallback einer durchgehend gesperrten Neuinstallation.</summary>
private static void InstallerUsesVerifiedCopyFallbackForNewInstall()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
var data = Path.Combine(directory, "data");
var moveCalls = 0;
var delays = new List<int>();
var engine = new InstallerEngine(
package,
install,
data,
false,
path => true,
(source, target) =>
{
moveCalls++;
throw new UnauthorizedAccessException("simulated permanent policy denial");
},
delays.Add);
engine.Install(false, null);
Assert(moveCalls == 8, "permanent move failure did not stop after eight attempts");
Assert(delays.SequenceEqual(new[] { 250, 500, 1000, 2000, 3000, 5000, 8000 }), "bounded retry schedule changed unexpectedly");
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "new", "verified copy fallback did not activate payload");
Assert(!Directory.GetDirectories(directory, "install.staging.*").Any(), "staging remained after copy fallback");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=directory_move_retry role=activate failed_attempt=7"), "final scheduled retry was not diagnosed");
Assert(log.Contains("exception_type=System.UnauthorizedAccessException"), "ACL failure type missing from retry diagnostics");
Assert(log.Contains("event=activation_fallback_complete method=verified_copy"), "copy fallback completion was not diagnosed");
Assert(log.Contains("activation_method=verified_copy_fallback"), "copy fallback missing from setup summary");
});
}
/// <summary>Prüft, dass ein Update bei dauerhaft gesperrtem Backup atomar und unverändert abbricht.</summary>
private static void InstallerStopsAfterBoundedUpdateMoveRetries()
{
InTemp(directory =>
{
var package = CreatePackage(directory, "new");
var install = Path.Combine(directory, "install");
Directory.CreateDirectory(install);
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName), "old");
File.WriteAllText(Path.Combine(install, InstallerEngine.ApplicationExeName + ".config"), "old-config");
var data = Path.Combine(directory, "data");
var moveCalls = 0;
var delays = new List<int>();
var engine = new InstallerEngine(
package,
install,
data,
false,
path => true,
(source, target) =>
{
moveCalls++;
throw new UnauthorizedAccessException("simulated permanent update policy denial");
},
delays.Add);
var exception = Capture<InvalidOperationException>(() => engine.Install(false, null));
Assert(moveCalls == 8, "permanent update move failure did not stop after eight attempts");
Assert(delays.SequenceEqual(new[] { 250, 500, 1000, 2000, 3000, 5000, 8000 }), "bounded update retry schedule changed unexpectedly");
Assert(File.ReadAllText(Path.Combine(install, InstallerEngine.ApplicationExeName)) == "old", "failed update modified the active installation");
Assert(!Directory.GetDirectories(directory, "install.staging.*").Any(), "staging remained after permanent update failure");
Assert(exception.Message.Contains("Fehlercode=SETUP-ACTIVATION"), "activation error code missing after update retries");
Assert(exception.Message.Contains("Kein Rollback erforderlich"), "pre-mutation update denial reported a rollback");
var log = File.ReadAllText(Directory.GetFiles(Path.Combine(data, "InstallerLogs"), "setup-*.log").Single());
Assert(log.Contains("event=directory_move_retry role=backup failed_attempt=7"), "final update retry was not diagnosed");
Assert(!log.Contains("event=activation_fallback_started"), "update incorrectly used the new-install copy fallback");
});
}
/// <summary>Prüft die Ablehnung nicht deklarierter Dateien und ausbrechender Manifestpfade.</summary>
private static void PackageManifestRejectsUndeclaredAndTraversalFiles()
{