diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs index 4b351c5..f0e0926 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, report.Target.TargetPath, 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, report.Target.TargetPath, null, userTrustPath); + + model2.RiskScore.ShouldBe(0); + model2.RecommendedAction.ShouldBe(RecommendedAction.Allow.ToString()); } } } 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/Services/SolutionMonitorService.cs b/MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs index 11e04a2..d4fac85 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,20 @@ public void Dispose() /// Solution open event arguments. private void OnAfterOpenSolution(object? sender, OpenSolutionEventArgs e) { - _ = 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); + + await 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!); + } + }).FileAndForget(nameof(SolutionMonitorService)); } /// @@ -112,9 +137,126 @@ 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 + }; + + this.gitWatcher.Changed += this.OnGitHeadChanged; + this.gitWatcher.EnableRaisingEvents = true; + } + 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) + { + 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)); + } + /// /// 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..b82be60 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 = !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); var activeRiskScore = 0; 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