using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; namespace StellaOps.Scanner.Analyzers.Native.Index.Tests; /// /// Unit tests for . /// public sealed class OfflineBuildIdIndexTests : IDisposable { private readonly string _tempDir; public OfflineBuildIdIndexTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"buildid-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); } public void Dispose() { if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); } } #region Loading Tests [Fact] public async Task LoadAsync_EmptyIndex_WhenNoPathConfigured() { var options = Options.Create(new BuildIdIndexOptions { IndexPath = null }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); Assert.True(index.IsLoaded); Assert.Equal(0, index.Count); } [Fact] public async Task LoadAsync_EmptyIndex_WhenFileNotFound() { var options = Options.Create(new BuildIdIndexOptions { IndexPath = "/nonexistent/file.ndjson" }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); Assert.True(index.IsLoaded); Assert.Equal(0, index.Count); } [Fact] public async Task LoadAsync_ParsesNdjsonEntries() { var indexPath = Path.Combine(_tempDir, "index.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"} {"build_id":"pe-cv:12345678-1234-1234-1234-123456789012-1","purl":"pkg:nuget/System.Text.Json@8.0.0","confidence":"inferred"} {"build_id":"macho-uuid:fedcba9876543210fedcba9876543210","purl":"pkg:brew/openssl@3.0.0","distro":"macos","confidence":"exact"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); Assert.True(index.IsLoaded); Assert.Equal(3, index.Count); } [Fact] public async Task LoadAsync_SkipsEmptyLines() { var indexPath = Path.Combine(_tempDir, "index-empty-lines.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} {"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); Assert.Equal(2, index.Count); } [Fact] public async Task LoadAsync_SkipsCommentLines() { var indexPath = Path.Combine(_tempDir, "index-comments.ndjson"); await File.WriteAllTextAsync(indexPath, """ # This is a comment {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} // Another comment style {"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); Assert.Equal(2, index.Count); } [Fact] public async Task LoadAsync_SkipsInvalidJsonLines() { var indexPath = Path.Combine(_tempDir, "index-invalid.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} not valid json at all {"build_id":"gnu-build-id:def456","purl":"pkg:deb/debian/libssl@1.1"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); Assert.Equal(2, index.Count); } #endregion #region Lookup Tests [Fact] public async Task LookupAsync_ReturnsNull_WhenNotFound() { var indexPath = Path.Combine(_tempDir, "index.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); var result = await index.LookupAsync("gnu-build-id:notfound"); Assert.Null(result); } [Fact] public async Task LookupAsync_ReturnsNull_ForNullOrEmpty() { var indexPath = Path.Combine(_tempDir, "index.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:abc123","purl":"pkg:deb/debian/libc6@2.31"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); Assert.Null(await index.LookupAsync(null!)); Assert.Null(await index.LookupAsync("")); Assert.Null(await index.LookupAsync(" ")); } [Fact] public async Task LookupAsync_FindsExactMatch() { var indexPath = Path.Combine(_tempDir, "index.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:abc123def456","purl":"pkg:deb/debian/libc6@2.31","version":"2.31","distro":"debian","confidence":"exact","indexed_at":"2025-01-15T10:00:00Z"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); var result = await index.LookupAsync("gnu-build-id:abc123def456"); Assert.NotNull(result); Assert.Equal("gnu-build-id:abc123def456", result.BuildId); Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl); Assert.Equal("2.31", result.Version); Assert.Equal("debian", result.SourceDistro); Assert.Equal(BuildIdConfidence.Exact, result.Confidence); } [Fact] public async Task LookupAsync_CaseInsensitive() { var indexPath = Path.Combine(_tempDir, "index.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:ABC123DEF456","purl":"pkg:deb/debian/libc6@2.31"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); // Query with lowercase var result = await index.LookupAsync("gnu-build-id:abc123def456"); Assert.NotNull(result); Assert.Equal("pkg:deb/debian/libc6@2.31", result.Purl); } #endregion #region Batch Lookup Tests [Fact] public async Task BatchLookupAsync_ReturnsFoundEntries() { var indexPath = Path.Combine(_tempDir, "index.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"} {"build_id":"gnu-build-id:bbb","purl":"pkg:deb/debian/libb@1.0"} {"build_id":"gnu-build-id:ccc","purl":"pkg:deb/debian/libc@1.0"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); var results = await index.BatchLookupAsync(["gnu-build-id:aaa", "gnu-build-id:notfound", "gnu-build-id:ccc"]); Assert.Equal(2, results.Count); Assert.Contains(results, r => r.Purl == "pkg:deb/debian/liba@1.0"); Assert.Contains(results, r => r.Purl == "pkg:deb/debian/libc@1.0"); } [Fact] public async Task BatchLookupAsync_SkipsNullAndEmpty() { var indexPath = Path.Combine(_tempDir, "index.ndjson"); await File.WriteAllTextAsync(indexPath, """ {"build_id":"gnu-build-id:aaa","purl":"pkg:deb/debian/liba@1.0"} """); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); var results = await index.BatchLookupAsync([null!, "", " ", "gnu-build-id:aaa"]); Assert.Single(results); Assert.Equal("pkg:deb/debian/liba@1.0", results[0].Purl); } #endregion #region Confidence Parsing Tests [Theory] [InlineData("exact", BuildIdConfidence.Exact)] [InlineData("EXACT", BuildIdConfidence.Exact)] [InlineData("inferred", BuildIdConfidence.Inferred)] [InlineData("Inferred", BuildIdConfidence.Inferred)] [InlineData("heuristic", BuildIdConfidence.Heuristic)] [InlineData("unknown", BuildIdConfidence.Heuristic)] // Defaults to heuristic [InlineData("", BuildIdConfidence.Heuristic)] public async Task LoadAsync_ParsesConfidenceLevels(string confidenceValue, BuildIdConfidence expected) { var indexPath = Path.Combine(_tempDir, "index.ndjson"); var entry = new { build_id = "gnu-build-id:test", purl = "pkg:test/test@1.0", confidence = confidenceValue }; await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(entry)); var options = Options.Create(new BuildIdIndexOptions { IndexPath = indexPath, RequireSignature = false }); var index = new OfflineBuildIdIndex(options, NullLogger.Instance); await index.LoadAsync(); var result = await index.LookupAsync("gnu-build-id:test"); Assert.NotNull(result); Assert.Equal(expected, result.Confidence); } #endregion }