using System.Collections.Immutable; using System.Formats.Tar; using System.IO.Compression; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Attestor.StandardPredicates.BinaryDiff; using StellaOps.Cli.Commands.Scan; using StellaOps.Cli.Services; using StellaOps.Cli.Services.Models; using StellaOps.Scanner.Analyzers.Native; using StellaOps.Scanner.Contracts; using StellaOps.TestKit; using Xunit; namespace StellaOps.Cli.Tests.Commands; [Trait("Category", TestCategories.Unit)] public sealed class BinaryDiffServiceTests { [Fact] public async Task ComputeDiffAsync_InvalidReference_ThrowsBinaryDiffException() { var service = CreateService(new TestOciRegistryClient(), new TestElfSectionHashExtractor(), TimeProvider.System); var request = new BinaryDiffRequest { BaseImageRef = "", TargetImageRef = "registry.example.com/app:2", Mode = BinaryDiffMode.Elf }; var exception = await Assert.ThrowsAsync(() => service.ComputeDiffAsync(request, null, CancellationToken.None)); Assert.Equal(BinaryDiffErrorCode.InvalidReference, exception.Code); } [Fact] public async Task ComputeDiffAsync_ExcludesUnchanged_WhenIncludeUnchangedFalse() { var baseRef = "registry.example.com/app:1"; var targetRef = "registry.example.com/app:2"; var baseLayer = CreateLayer( ("usr/bin/alpha", CreateElfBytes('a')), ("usr/bin/beta", CreateElfBytes('a'))); var targetLayer = CreateLayer( ("usr/bin/alpha", CreateElfBytes('a')), ("usr/bin/beta", CreateElfBytes('b')), ("usr/bin/gamma", CreateElfBytes('b'))); var registry = new TestOciRegistryClient(); registry.AddImage(baseRef, "sha256:base", CreateManifest("sha256:layer-base", baseLayer.Length)); registry.AddImage(targetRef, "sha256:target", CreateManifest("sha256:layer-target", targetLayer.Length)); registry.AddBlob("sha256:layer-base", baseLayer); registry.AddBlob("sha256:layer-target", targetLayer); var service = CreateService(registry, new TestElfSectionHashExtractor(), TimeProvider.System); var result = await service.ComputeDiffAsync( new BinaryDiffRequest { BaseImageRef = baseRef, TargetImageRef = targetRef, Mode = BinaryDiffMode.Elf, IncludeUnchanged = false, Platform = new BinaryDiffPlatform { Os = "linux", Architecture = "amd64" } }, null, CancellationToken.None); Assert.Equal(2, result.Findings.Length); Assert.All(result.Findings, finding => Assert.NotEqual(ChangeType.Unchanged, finding.ChangeType)); var paths = result.Findings.Select(finding => finding.Path).ToArray(); var sortedPaths = paths.OrderBy(path => path, StringComparer.Ordinal).ToArray(); Assert.Equal(sortedPaths, paths); Assert.Equal(3, result.Summary.TotalBinaries); Assert.Equal(2, result.Summary.Modified); Assert.Equal(1, result.Summary.Unchanged); Assert.Equal(1, result.Summary.Added); Assert.Equal(0, result.Summary.Removed); } [Fact] public async Task ComputeDiffAsync_UsesTimeProviderForMetadata() { var baseRef = "registry.example.com/app:1"; var targetRef = "registry.example.com/app:2"; var baseLayer = CreateLayer(("usr/bin/app", CreateElfBytes('a'))); var targetLayer = CreateLayer(("usr/bin/app", CreateElfBytes('b'))); var registry = new TestOciRegistryClient(); registry.AddImage(baseRef, "sha256:base", CreateManifest("sha256:layer-base", baseLayer.Length)); registry.AddImage(targetRef, "sha256:target", CreateManifest("sha256:layer-target", targetLayer.Length)); registry.AddBlob("sha256:layer-base", baseLayer); registry.AddBlob("sha256:layer-target", targetLayer); var fixedTime = new DateTimeOffset(2026, 1, 13, 1, 0, 0, TimeSpan.Zero); var service = CreateService(registry, new TestElfSectionHashExtractor(), new FixedTimeProvider(fixedTime)); var result = await service.ComputeDiffAsync( new BinaryDiffRequest { BaseImageRef = baseRef, TargetImageRef = targetRef, Mode = BinaryDiffMode.Elf }, null, CancellationToken.None); Assert.Equal(fixedTime, result.Metadata.AnalysisTimestamp); } private static BinaryDiffService CreateService( IOciRegistryClient registryClient, IElfSectionHashExtractor extractor, TimeProvider timeProvider) { var options = Options.Create(new BinaryDiffOptions { ToolVersion = "test" }); return new BinaryDiffService( registryClient, extractor, options, timeProvider, NullLogger.Instance); } private static OciManifest CreateManifest(string layerDigest, long size) { return new OciManifest { Layers = new List { new OciDescriptor { Digest = layerDigest, Size = size, MediaType = "application/vnd.oci.image.layer.v1.tar+gzip" } } }; } private static byte[] CreateLayer(params (string Path, byte[] Content)[] entries) { using var output = new MemoryStream(); using (var gzip = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) using (var tarWriter = new TarWriter(gzip, TarEntryFormat.Pax)) { foreach (var entry in entries) { var tarEntry = new PaxTarEntry(TarEntryType.RegularFile, entry.Path) { Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead, ModificationTime = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero), DataStream = new MemoryStream(entry.Content, writable: false) }; tarWriter.WriteEntry(tarEntry); } } return output.ToArray(); } private static byte[] CreateElfBytes(char marker) { return [ 0x7F, (byte)'E', (byte)'L', (byte)'F', (byte)marker ]; } private static ElfSectionHashSet CreateHashSet(string path, char marker) { var hash = new string(marker, 64); var section = new ElfSectionHash { Name = ".text", Offset = 0, Size = 16, Sha256 = hash, Blake3 = null, SectionType = ElfSectionType.ProgBits, Flags = ElfSectionFlags.Alloc }; return new ElfSectionHashSet { FilePath = path, FileHash = hash, BuildId = "build-" + marker, Sections = ImmutableArray.Create(section), ExtractedAt = new DateTimeOffset(2026, 1, 13, 0, 0, 0, TimeSpan.Zero), ExtractorVersion = "test" }; } private sealed class TestOciRegistryClient : IOciRegistryClient { private readonly Dictionary _digestsByReference = new(StringComparer.Ordinal); private readonly Dictionary _manifestsByDigest = new(StringComparer.Ordinal); private readonly Dictionary _blobsByDigest = new(StringComparer.Ordinal); public void AddImage(string reference, string digest, OciManifest manifest) { _digestsByReference[reference] = digest; _manifestsByDigest[digest] = manifest; } public void AddBlob(string digest, byte[] blob) { _blobsByDigest[digest] = blob; } public Task ResolveDigestAsync(OciImageReference reference, CancellationToken cancellationToken = default) { if (_digestsByReference.TryGetValue(reference.Original, out var digest)) { return Task.FromResult(digest); } throw new InvalidOperationException($"Digest not configured for {reference.Original}"); } public Task ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default) { throw new NotSupportedException("ResolveTagAsync is not used by these tests."); } public Task ListReferrersAsync( OciImageReference reference, string digest, CancellationToken cancellationToken = default) { throw new NotSupportedException("ListReferrersAsync is not used by these tests."); } public Task> GetReferrersAsync( string registry, string repository, string digest, string? artifactType = null, CancellationToken cancellationToken = default) { throw new NotSupportedException("GetReferrersAsync is not used by these tests."); } public Task GetManifestAsync( OciImageReference reference, string digest, CancellationToken cancellationToken = default) { if (_manifestsByDigest.TryGetValue(digest, out var manifest)) { return Task.FromResult(manifest); } throw new InvalidOperationException($"Manifest not configured for {digest}"); } public Task GetBlobAsync( OciImageReference reference, string digest, CancellationToken cancellationToken = default) { if (_blobsByDigest.TryGetValue(digest, out var blob)) { return Task.FromResult(blob); } throw new InvalidOperationException($"Blob not configured for {digest}"); } } private sealed class TestElfSectionHashExtractor : IElfSectionHashExtractor { public Task ExtractAsync(string elfPath, CancellationToken cancellationToken = default) { var bytes = File.ReadAllBytes(elfPath); return ExtractFromBytesAsync(bytes, elfPath, cancellationToken); } public Task ExtractFromBytesAsync( ReadOnlyMemory elfBytes, string virtualPath, CancellationToken cancellationToken = default) { if (elfBytes.Length < 5) { return Task.FromResult(null); } var marker = (char)elfBytes.Span[4]; var normalized = marker switch { 'a' => 'a', 'b' => 'b', _ => 'c' }; return Task.FromResult(CreateHashSet(virtualPath, normalized)); } } private sealed class FixedTimeProvider : TimeProvider { private readonly DateTimeOffset _fixedTime; public FixedTimeProvider(DateTimeOffset fixedTime) { _fixedTime = fixedTime; } public override DateTimeOffset GetUtcNow() => _fixedTime; } }