Files
git.stella-ops.org/src/Cli/__Tests/StellaOps.Cli.Tests/Commands/BinaryDiffServiceTests.cs
2026-01-13 18:53:39 +02:00

322 lines
11 KiB
C#

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<BinaryDiffException>(() =>
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<BinaryDiffService>.Instance);
}
private static OciManifest CreateManifest(string layerDigest, long size)
{
return new OciManifest
{
Layers = new List<OciDescriptor>
{
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<string, string> _digestsByReference = new(StringComparer.Ordinal);
private readonly Dictionary<string, OciManifest> _manifestsByDigest = new(StringComparer.Ordinal);
private readonly Dictionary<string, byte[]> _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<string> 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<string> ResolveTagAsync(string registry, string repository, string tag, CancellationToken cancellationToken = default)
{
throw new NotSupportedException("ResolveTagAsync is not used by these tests.");
}
public Task<OciReferrersResponse> ListReferrersAsync(
OciImageReference reference,
string digest,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException("ListReferrersAsync is not used by these tests.");
}
public Task<IReadOnlyList<OciReferrerDescriptor>> 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<OciManifest> 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<byte[]> 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<ElfSectionHashSet?> ExtractAsync(string elfPath, CancellationToken cancellationToken = default)
{
var bytes = File.ReadAllBytes(elfPath);
return ExtractFromBytesAsync(bytes, elfPath, cancellationToken);
}
public Task<ElfSectionHashSet?> ExtractFromBytesAsync(
ReadOnlyMemory<byte> elfBytes,
string virtualPath,
CancellationToken cancellationToken = default)
{
if (elfBytes.Length < 5)
{
return Task.FromResult<ElfSectionHashSet?>(null);
}
var marker = (char)elfBytes.Span[4];
var normalized = marker switch
{
'a' => 'a',
'b' => 'b',
_ => 'c'
};
return Task.FromResult<ElfSectionHashSet?>(CreateHashSet(virtualPath, normalized));
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FixedTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}