using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Collections.ObjectModel; using System.Diagnostics.Metrics; using System.IO; using System.Linq; using System.Text; using System.Threading; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scanner.Analyzers.Lang; using StellaOps.Scanner.Analyzers.Lang.Plugin; using StellaOps.Scanner.Analyzers.OS.Abstractions; using StellaOps.Scanner.Analyzers.OS.Plugin; using StellaOps.Scanner.Core.Contracts; using StellaOps.Scanner.Surface.Env; using StellaOps.Scanner.Surface.FS; using StellaOps.Scanner.Surface.Secrets; using StellaOps.Scanner.Surface.Validation; using StellaOps.Scanner.Worker.Diagnostics; using StellaOps.Scanner.Worker.Processing; using StellaOps.Scanner.Worker.Tests.TestInfrastructure; using Xunit; using WorkerOptions = StellaOps.Scanner.Worker.Options.ScannerWorkerOptions; namespace StellaOps.Scanner.Worker.Tests; public sealed class CompositeScanAnalyzerDispatcherTests { [Fact] public async Task ExecuteAsync_RunsLanguageAnalyzers_StoresResults() { using var workspace = new TempDirectory(); using var cacheRoot = new TempDirectory(); Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.test"); Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "unit-test-bucket"); Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_ROOT", cacheRoot.Path); Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", "inline"); Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", "testtenant"); Environment.SetEnvironmentVariable( "SURFACE_SECRET_TESTTENANT_SCANNERWORKERLANGUAGEANALYZERS_REGISTRY_DEFAULT", Convert.ToBase64String(Encoding.UTF8.GetBytes("token-placeholder"))); var metadata = new Dictionary(StringComparer.Ordinal) { { ScanMetadataKeys.RootFilesystemPath, workspace.Path }, { ScanMetadataKeys.WorkspacePath, workspace.Path }, }; var osCatalog = new FakeOsCatalog(); var analyzer = new FakeLanguageAnalyzer(); var languageCatalog = new FakeLanguageCatalog(analyzer); long hits = 0; long misses = 0; MeterListener? meterListener = null; ServiceProvider? services = null; try { var serviceCollection = new ServiceCollection(); serviceCollection.AddSingleton(new ConfigurationBuilder().Build()); serviceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); serviceCollection.AddSingleton(TimeProvider.System); serviceCollection.AddSurfaceEnvironment(options => options.ComponentName = "Scanner.Worker"); serviceCollection.AddSurfaceValidation(); serviceCollection.AddSurfaceFileCache(options => options.RootDirectory = cacheRoot.Path); serviceCollection.AddSurfaceSecrets(); var metrics = new ScannerWorkerMetrics(); serviceCollection.AddSingleton(metrics); meterListener = new MeterListener(); meterListener.InstrumentPublished = (instrument, listener) => { if (instrument.Meter.Name == ScannerWorkerInstrumentation.MeterName && (instrument.Name == "scanner_worker_language_cache_hits_total" || instrument.Name == "scanner_worker_language_cache_misses_total")) { listener.EnableMeasurementEvents(instrument); } }; meterListener.SetMeasurementEventCallback((instrument, measurement, tags, state) => { if (instrument.Name == "scanner_worker_language_cache_hits_total") { Interlocked.Add(ref hits, measurement); } else if (instrument.Name == "scanner_worker_language_cache_misses_total") { Interlocked.Add(ref misses, measurement); } }); meterListener.Start(); services = serviceCollection.BuildServiceProvider(); var scopeFactory = services.GetRequiredService(); var loggerFactory = services.GetRequiredService(); var options = Microsoft.Extensions.Options.Options.Create(new WorkerOptions()); var dispatcher = new CompositeScanAnalyzerDispatcher( scopeFactory, osCatalog, languageCatalog, options, loggerFactory.CreateLogger(), metrics, new TestCryptoHash()); var lease = new TestJobLease(metadata); var context = new ScanJobContext(lease, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None); await dispatcher.ExecuteAsync(context, CancellationToken.None); // Re-run with a new context to exercise cache reuse. var leaseSecond = new TestJobLease(metadata); var contextSecond = new ScanJobContext(leaseSecond, TimeProvider.System, TimeProvider.System.GetUtcNow(), CancellationToken.None); await dispatcher.ExecuteAsync(contextSecond, CancellationToken.None); meterListener.RecordObservableInstruments(); Assert.Equal(1, analyzer.InvocationCount); Assert.True(context.Analysis.TryGet>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results)); Assert.Single(results); Assert.True(context.Analysis.TryGet>(ScanAnalysisKeys.LanguageComponentFragments, out var fragments)); Assert.False(fragments.IsDefaultOrEmpty); Assert.True(context.Analysis.GetLayerFragments().Any(fragment => fragment.Components.Any(component => component.Identity.Name == "demo-package"))); Assert.Equal(1, hits); Assert.Equal(1, misses); } finally { Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null); Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", null); Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_ROOT", null); Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", null); Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", null); Environment.SetEnvironmentVariable("SURFACE_SECRET_TESTTENANT_SCANNERWORKERLANGUAGEANALYZERS_REGISTRY_DEFAULT", null); meterListener?.Dispose(); services?.Dispose(); } } private sealed class FakeOsCatalog : IOSAnalyzerPluginCatalog { public IReadOnlyCollection Plugins => Array.Empty(); public void LoadFromDirectory(string directory, bool seal = true) { } public IReadOnlyList CreateAnalyzers(IServiceProvider services) => Array.Empty(); } private sealed class FakeLanguageCatalog : ILanguageAnalyzerPluginCatalog { private readonly IReadOnlyList _analyzers; public FakeLanguageCatalog(params ILanguageAnalyzer[] analyzers) { _analyzers = analyzers ?? Array.Empty(); } public IReadOnlyCollection Plugins => Array.Empty(); public void LoadFromDirectory(string directory, bool seal = true) { } public IReadOnlyList CreateAnalyzers(IServiceProvider services) => _analyzers; } private sealed class FakeLanguageAnalyzer : ILanguageAnalyzer { public string Id => "lang.fake"; public string DisplayName => "Fake Language Analyzer"; public int InvocationCount { get; private set; } public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken) { Interlocked.Increment(ref _invocationCount); InvocationCount = _invocationCount; writer.AddFromPurl( analyzerId: Id, purl: "pkg:npm/demo-package@1.0.0", name: "demo-package", version: "1.0.0", type: "npm"); return ValueTask.CompletedTask; } private int _invocationCount; } private sealed class TestJobLease : IScanJobLease { private readonly Dictionary _metadata; public TestJobLease(Dictionary metadata) { _metadata = metadata; JobId = Guid.NewGuid().ToString("n"); ScanId = $"scan-{Guid.NewGuid():n}"; Attempt = 1; EnqueuedAtUtc = DateTimeOffset.UtcNow.AddSeconds(-1); LeasedAtUtc = DateTimeOffset.UtcNow; LeaseDuration = TimeSpan.FromMinutes(5); } public string JobId { get; } public string ScanId { get; } public int Attempt { get; } public DateTimeOffset EnqueuedAtUtc { get; } public DateTimeOffset LeasedAtUtc { get; } public TimeSpan LeaseDuration { get; } public IReadOnlyDictionary Metadata => _metadata; public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask; public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask; public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask; public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; } private sealed class TempDirectory : IDisposable { public TempDirectory() { Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stellaops-test-{Guid.NewGuid():n}"); Directory.CreateDirectory(Path); } public string Path { get; } public void Dispose() { try { if (Directory.Exists(Path)) { Directory.Delete(Path, recursive: true); } } catch { } } } }