From f203c69c740fb2f165216584d02d888c83efdbea Mon Sep 17 00:00:00 2001 From: Andreas Saurwein Date: Thu, 18 Jun 2026 17:15:37 +0100 Subject: [PATCH 1/3] Monitor Git branch changes to keep trust sharing consistent; trust store path override for tests Refactored BuildBlockDialogViewModel to allow an optional user trust store path override and updated related tests for isolation. Enhanced SolutionMonitorService to watch for Git HEAD changes, handling submodules and worktrees, and re-apply trust sharing preferences on HEAD updates. Git watcher is now started and stopped with solution lifecycle events. Note: only for the Visual Studio extension. --- .../BuildBlockDialogViewModelTests.cs | 49 +++---- .../Services/SolutionMonitorService.cs | 128 ++++++++++++++++++ .../ToolWindows/BuildBlockDialogViewModel.cs | 17 ++- 3 files changed, 159 insertions(+), 35 deletions(-) diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs index 4b351c5..9a251fe 100644 --- a/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs +++ b/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs @@ -77,47 +77,30 @@ public void Constructor_WithTrustedFindings_CalculatesCorrectRiskScore() report.FilesScanned.Add(fileRecord); - var userTrustPath = new TrustStoreService().GetDefaultUserTrustPath(); - var model = new BuildBlockDialogViewModel(report, this.tempDir); + var userTrustPath = Path.Combine(this.tempDir, "user-trust.json"); + var model = new BuildBlockDialogViewModel(report, this.tempDir, null, userTrustPath); // Initially, the finding is not trusted. model.RiskScore.ShouldBe(20); // Now we write a trust entry for it. var trustStoreService = new TrustStoreService(); - var userTrustStore = trustStoreService.Load(userTrustPath); - var originalDecisions = new List(userTrustStore.Decisions); - try + trustStoreService.AddDecision(userTrustPath, new TrustDecisionEntry { - trustStoreService.AddDecision(userTrustPath, new TrustDecisionEntry - { - DecisionId = Guid.NewGuid().ToString("N"), - Scope = "Finding", - SubjectHash = "fingerprint-1", - Decision = "Trust", - Reason = "Test trust", - UserSid = "TestSid", - CreatedAtUtc = DateTimeOffset.UtcNow - }); - - var model2 = new BuildBlockDialogViewModel(report, this.tempDir); - - model2.RiskScore.ShouldBe(0); - model2.RecommendedAction.ShouldBe(RecommendedAction.Allow.ToString()); - } - finally - { - // Restore the original user trust store to not pollute the host. - userTrustStore.Decisions.Clear(); - - foreach (var d in originalDecisions) - { - userTrustStore.Decisions.Add(d); - } - - trustStoreService.Save(userTrustPath, userTrustStore); - } + DecisionId = Guid.NewGuid().ToString("N"), + Scope = "Finding", + SubjectHash = "fingerprint-1", + Decision = "Trust", + Reason = "Test trust", + UserSid = "TestSid", + CreatedAtUtc = DateTimeOffset.UtcNow + }); + + var model2 = new BuildBlockDialogViewModel(report, this.tempDir, null, userTrustPath); + + model2.RiskScore.ShouldBe(0); + model2.RecommendedAction.ShouldBe(RecommendedAction.Allow.ToString()); } } } diff --git a/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs b/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs index 11e04a2..f7122a2 100644 --- a/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs +++ b/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs @@ -21,6 +21,11 @@ internal sealed class SolutionMonitorService : IDisposable private bool isStarted; private string? lastScannedSolutionPath; + /// + /// Watcher for Git HEAD file changes. + /// + private FileSystemWatcher? gitWatcher; + /// /// Initializes a new instance of the class. /// @@ -57,6 +62,13 @@ public async Task StartAsync(CancellationToken cancellationToken) SolutionEvents.OnBeforeOpenProject += this.OnBeforeOpenProject; this.isStarted = true; + var openSolutionPath = SolutionDiscoveryService.GetOpenSolutionPath(); + + if (!string.IsNullOrWhiteSpace(openSolutionPath)) + { + this.StartGitWatcher(openSolutionPath!); + } + await this.package.UiFeedbackService.WriteLineAsync("Solution monitor started.", CancellationToken.None); _ = this.QueueScanAsync(null, cancellationToken); } @@ -74,6 +86,7 @@ public void Dispose() this.isStarted = false; } + this.StopGitWatcher(); this.scanGate.Dispose(); } @@ -84,8 +97,16 @@ public void Dispose() /// Solution open event arguments. private void OnAfterOpenSolution(object? sender, OpenSolutionEventArgs e) { + ThreadHelper.ThrowIfNotOnUIThread(); _ = this.package.UiFeedbackService.WriteLineAsync("Solution opened.", CancellationToken.None); _ = this.QueueScanAsync(null, this.package.DisposalToken); + + var openSolutionPath = SolutionDiscoveryService.GetOpenSolutionPath(); + + if (!string.IsNullOrWhiteSpace(openSolutionPath)) + { + this.StartGitWatcher(openSolutionPath!); + } } /// @@ -112,9 +133,116 @@ private void OnAfterCloseSolution(object? sender, EventArgs e) this.lastScannedSolutionPath = null; } + this.StopGitWatcher(); + _ = this.package.OnSolutionUnloadedAsync(); } + /// + /// Resolves the actual git directory path, handling submodules and worktrees. + /// + /// The repository root directory. + /// The resolved git directory path, or null. + private static string? GetGitDir(string repositoryRoot) + { + var gitPath = Path.Combine(repositoryRoot, ".git"); + + if (Directory.Exists(gitPath)) + { + return gitPath; + } + + if (File.Exists(gitPath)) + { + try + { + var content = File.ReadAllText(gitPath).Trim(); + + if (content.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase)) + { + var relativePath = content.Substring(7).Trim(); + var absolutePath = Path.IsPathRooted(relativePath) + ? relativePath + : Path.GetFullPath(Path.Combine(repositoryRoot, relativePath)); + + if (Directory.Exists(absolutePath)) + { + return absolutePath; + } + } + } + catch + { + // Ignore + } + } + + return null; + } + + /// + /// Starts monitoring Git HEAD changes for the specified solution directory. + /// + /// The path to the solution. + private void StartGitWatcher(string solutionPath) + { + this.StopGitWatcher(); + + var repoRoot = SolutionDiscoveryService.TryResolveRepositoryRoot(solutionPath); + + if (string.IsNullOrWhiteSpace(repoRoot)) + { + return; + } + + var gitDir = GetGitDir(repoRoot!); + + if (string.IsNullOrWhiteSpace(gitDir) || !Directory.Exists(gitDir)) + { + return; + } + + try + { + this.gitWatcher = new FileSystemWatcher(gitDir!, "HEAD") + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, + EnableRaisingEvents = true + }; + + this.gitWatcher.Changed += this.OnGitHeadChanged; + } + catch (Exception ex) + { + _ = this.package.UiFeedbackService.WriteLineAsync($"Failed to start Git watcher: {ex.Message}", CancellationToken.None); + } + } + + /// + /// Stops and disposes the Git HEAD watcher. + /// + private void StopGitWatcher() + { + if (this.gitWatcher != null) + { + this.gitWatcher.EnableRaisingEvents = false; + this.gitWatcher.Changed -= this.OnGitHeadChanged; + this.gitWatcher.Dispose(); + this.gitWatcher = null; + } + } + + /// + /// Handles Git HEAD file change events. + /// + /// Event sender. + /// File system event arguments. + private void OnGitHeadChanged(object sender, FileSystemEventArgs e) + { + _ = this.package.UiFeedbackService.WriteLineAsync("Git HEAD changed. Re-applying trust sharing preference.", CancellationToken.None); + _ = this.package.ApplyTrustSharingPreferenceAsync(); + } + /// /// Acquires the scan gate, resolves the target path, runs the scanner, and raises . /// diff --git a/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs b/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs index 906aef3..65dc749 100644 --- a/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs +++ b/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs @@ -113,7 +113,7 @@ public BuildBlockDialogViewModel(ScanReport report) /// The scan report. /// The solution path. public BuildBlockDialogViewModel(ScanReport report, string? solutionPath) - : this(report, solutionPath, null) + : this(report, solutionPath, null, null) { } @@ -124,6 +124,18 @@ public BuildBlockDialogViewModel(ScanReport report, string? solutionPath) /// The solution path. /// The project path filter. public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string? projectPathFilter) + : this(report, solutionPath, projectPathFilter, null) + { + } + + /// + /// Initializes a new instance of the class with an explicit solution path, optional project path filter, and optional user-level trust store path override. + /// + /// The scan report. + /// The solution path. + /// The project path filter. + /// Optional user-level trust store path to override the default. + public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string? projectPathFilter, string? userTrustPath) { if (report == null) { @@ -140,7 +152,8 @@ public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string report.Target.TargetPath.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase) || report.Target.TargetPath.EndsWith(".proj", StringComparison.OrdinalIgnoreCase))); var currentProjectPath = !string.IsNullOrWhiteSpace(projectPathFilter) ? projectPathFilter : (isProject ? report.Target.TargetPath : null); - var trustStore = trustStoreService.LoadMergedTrustStore(trustStoreService.GetDefaultUserTrustPath(), solutionPath, currentProjectPath); + var userPath = userTrustPath ?? trustStoreService.GetDefaultUserTrustPath(); + var trustStore = trustStoreService.LoadMergedTrustStore(userPath, solutionPath, currentProjectPath); var signatureCache = new Dictionary(StringComparer.OrdinalIgnoreCase); var projectTrustStoreCache = new Dictionary(StringComparer.OrdinalIgnoreCase); var activeRiskScore = 0; From 082683278e0ed1aa72f147b46abf207209fb0bf1 Mon Sep 17 00:00:00 2001 From: Andreas Saurwein Date: Thu, 18 Jun 2026 17:34:36 +0100 Subject: [PATCH 2/3] fixing up minor issues --- .../BuildBlockDialogViewModelTests.cs | 4 +- .../Services/SolutionMonitorService.cs | 38 +++++++++++++------ .../ToolWindows/BuildBlockDialogViewModel.cs | 2 +- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs index 9a251fe..f0e0926 100644 --- a/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs +++ b/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs @@ -78,7 +78,7 @@ public void Constructor_WithTrustedFindings_CalculatesCorrectRiskScore() report.FilesScanned.Add(fileRecord); var userTrustPath = Path.Combine(this.tempDir, "user-trust.json"); - var model = new BuildBlockDialogViewModel(report, this.tempDir, null, userTrustPath); + var model = new BuildBlockDialogViewModel(report, report.Target.TargetPath, null, userTrustPath); // Initially, the finding is not trusted. model.RiskScore.ShouldBe(20); @@ -97,7 +97,7 @@ public void Constructor_WithTrustedFindings_CalculatesCorrectRiskScore() CreatedAtUtc = DateTimeOffset.UtcNow }); - var model2 = new BuildBlockDialogViewModel(report, this.tempDir, null, userTrustPath); + var model2 = new BuildBlockDialogViewModel(report, report.Target.TargetPath, null, userTrustPath); model2.RiskScore.ShouldBe(0); model2.RecommendedAction.ShouldBe(RecommendedAction.Allow.ToString()); diff --git a/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs b/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs index f7122a2..d4fac85 100644 --- a/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs +++ b/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs @@ -97,16 +97,20 @@ public void Dispose() /// Solution open event arguments. private void OnAfterOpenSolution(object? sender, OpenSolutionEventArgs e) { - ThreadHelper.ThrowIfNotOnUIThread(); - _ = this.package.UiFeedbackService.WriteLineAsync("Solution opened.", CancellationToken.None); - _ = this.QueueScanAsync(null, this.package.DisposalToken); + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(this.package.DisposalToken); - var openSolutionPath = SolutionDiscoveryService.GetOpenSolutionPath(); + await this.package.UiFeedbackService.WriteLineAsync("Solution opened.", CancellationToken.None); + _ = this.QueueScanAsync(null, this.package.DisposalToken); - if (!string.IsNullOrWhiteSpace(openSolutionPath)) - { - this.StartGitWatcher(openSolutionPath!); - } + var openSolutionPath = SolutionDiscoveryService.GetOpenSolutionPath(); + + if (!string.IsNullOrWhiteSpace(openSolutionPath)) + { + this.StartGitWatcher(openSolutionPath!); + } + }).FileAndForget(nameof(SolutionMonitorService)); } /// @@ -206,11 +210,11 @@ private void StartGitWatcher(string solutionPath) { this.gitWatcher = new FileSystemWatcher(gitDir!, "HEAD") { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, - EnableRaisingEvents = true + NotifyFilter = NotifyFilters.LastWrite }; this.gitWatcher.Changed += this.OnGitHeadChanged; + this.gitWatcher.EnableRaisingEvents = true; } catch (Exception ex) { @@ -239,8 +243,18 @@ private void StopGitWatcher() /// File system event arguments. private void OnGitHeadChanged(object sender, FileSystemEventArgs e) { - _ = this.package.UiFeedbackService.WriteLineAsync("Git HEAD changed. Re-applying trust sharing preference.", CancellationToken.None); - _ = this.package.ApplyTrustSharingPreferenceAsync(); + ThreadHelper.JoinableTaskFactory.RunAsync(async delegate + { + try + { + await this.package.UiFeedbackService.WriteLineAsync("Git HEAD changed. Re-applying trust sharing preference.", CancellationToken.None); + await this.package.ApplyTrustSharingPreferenceAsync(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to apply trust sharing preference: {ex.Message}"); + } + }).FileAndForget(nameof(SolutionMonitorService)); } /// diff --git a/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs b/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs index 65dc749..b82be60 100644 --- a/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs +++ b/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs @@ -152,7 +152,7 @@ public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string report.Target.TargetPath.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase) || report.Target.TargetPath.EndsWith(".proj", StringComparison.OrdinalIgnoreCase))); var currentProjectPath = !string.IsNullOrWhiteSpace(projectPathFilter) ? projectPathFilter : (isProject ? report.Target.TargetPath : null); - var userPath = userTrustPath ?? trustStoreService.GetDefaultUserTrustPath(); + var userPath = !string.IsNullOrWhiteSpace(userTrustPath) ? userTrustPath! : trustStoreService.GetDefaultUserTrustPath(); var trustStore = trustStoreService.LoadMergedTrustStore(userPath, solutionPath, currentProjectPath); var signatureCache = new Dictionary(StringComparer.OrdinalIgnoreCase); var projectTrustStoreCache = new Dictionary(StringComparer.OrdinalIgnoreCase); From 8294341354dbb9b136a07e4eac29648ae4be39b3 Mon Sep 17 00:00:00 2001 From: Andreas Saurwein Date: Thu, 18 Jun 2026 17:35:15 +0100 Subject: [PATCH 3/3] and the version bump --- MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj | 2 +- MSBuildGuard.VisualStudio/source.extension.vsixmanifest | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj b/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj index 742f051..fedf5d8 100644 --- a/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj +++ b/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj @@ -37,7 +37,7 @@ MSBuildGuard.VisualStudio MSBuildGuard MSBuildGuard - 0.3.1 + 0.3.2 False Hefaistos68 Hefaistos68.dev diff --git a/MSBuildGuard.VisualStudio/source.extension.vsixmanifest b/MSBuildGuard.VisualStudio/source.extension.vsixmanifest index 4e673f6..42f8dc7 100644 --- a/MSBuildGuard.VisualStudio/source.extension.vsixmanifest +++ b/MSBuildGuard.VisualStudio/source.extension.vsixmanifest @@ -1,6 +1,6 @@ - + MSBuild Guard for Visual Studio Real-time MSBuild security review, trust workflows, build blocking, and dynamic policy enforcement for Visual Studio. https://github.com/Hefaistos68/MSBuildGuard