audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,129 @@
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Commands.Scan;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class BinaryDiffCommandTests
{
private readonly IServiceProvider _services;
private readonly Option<bool> _verboseOption;
private readonly CancellationToken _cancellationToken;
public BinaryDiffCommandTests()
{
_services = new ServiceCollection().BuildServiceProvider();
_verboseOption = new Option<bool>("--verbose", new[] { "-v" })
{
Description = "Enable verbose output"
};
_cancellationToken = CancellationToken.None;
}
[Fact]
public void BuildDiffCommand_HasRequiredOptions()
{
var command = BuildDiffCommand();
Assert.Contains(command.Options, option => HasAlias(option, "--base", "-b"));
Assert.Contains(command.Options, option => HasAlias(option, "--target", "-t"));
Assert.Contains(command.Options, option => HasAlias(option, "--mode", "-m"));
Assert.Contains(command.Options, option => HasAlias(option, "--emit-dsse", "-d"));
Assert.Contains(command.Options, option => HasAlias(option, "--signing-key"));
Assert.Contains(command.Options, option => HasAlias(option, "--format", "-f"));
Assert.Contains(command.Options, option => HasAlias(option, "--platform", "-p"));
Assert.Contains(command.Options, option => HasAlias(option, "--include-unchanged"));
Assert.Contains(command.Options, option => HasAlias(option, "--sections"));
Assert.Contains(command.Options, option => HasAlias(option, "--registry-auth"));
Assert.Contains(command.Options, option => HasAlias(option, "--timeout"));
Assert.Contains(command.Options, option => HasAlias(option, "--verbose", "-v"));
}
[Fact]
public void BuildDiffCommand_RequiresBaseAndTarget()
{
var command = BuildDiffCommand();
var baseOption = FindOption(command, "--base");
var targetOption = FindOption(command, "--target");
Assert.NotNull(baseOption);
Assert.NotNull(targetOption);
Assert.Equal(1, baseOption!.Arity.MinimumNumberOfValues);
Assert.Equal(1, targetOption!.Arity.MinimumNumberOfValues);
}
[Fact]
public void DiffCommand_ParsesMinimalArgs()
{
var root = BuildRoot(out _);
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2");
Assert.Empty(result.Errors);
}
[Fact]
public void DiffCommand_FailsWhenBaseMissing()
{
var root = BuildRoot(out _);
var result = root.Parse("scan diff --target registry.example.com/app:2");
Assert.NotEmpty(result.Errors);
}
[Fact]
public void DiffCommand_AcceptsSectionsTokens()
{
var root = BuildRoot(out var diffCommand);
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2 --sections .text,.rodata --sections .data");
Assert.Empty(result.Errors);
var sectionsOption = diffCommand.Options
.OfType<Option<string[]>>()
.Single(option => HasAlias(option, "--sections"));
Assert.True(sectionsOption.AllowMultipleArgumentsPerToken);
}
private Command BuildDiffCommand()
{
return BinaryDiffCommandGroup.BuildDiffCommand(_services, _verboseOption, _cancellationToken);
}
private RootCommand BuildRoot(out Command diffCommand)
{
diffCommand = BuildDiffCommand();
var scan = new Command("scan", "Scanner operations")
{
diffCommand
};
return new RootCommand { scan };
}
private static Option? FindOption(Command command, string alias)
{
return command.Options.FirstOrDefault(option =>
option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
option.Aliases.Contains(alias));
}
private static bool HasAlias(Option option, params string[] aliases)
{
foreach (var alias in aliases)
{
if (option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
option.Aliases.Contains(alias))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,132 @@
using System.CommandLine;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Commands.Scan;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class BinaryDiffCommandTests
{
private readonly IServiceProvider _services;
private readonly Option<bool> _verboseOption;
private readonly CancellationToken _cancellationToken;
public BinaryDiffCommandTests()
{
_services = new ServiceCollection().BuildServiceProvider();
_verboseOption = new Option<bool>("--verbose", new[] { "-v" })
{
Description = "Enable verbose output"
};
_cancellationToken = CancellationToken.None;
}
[Fact]
public void BuildDiffCommand_HasRequiredOptions()
{
var command = BuildDiffCommand();
Assert.Contains(command.Options, option => HasAlias(option, "--base", "-b"));
Assert.Contains(command.Options, option => HasAlias(option, "--target", "-t"));
Assert.Contains(command.Options, option => HasAlias(option, "--mode", "-m"));
Assert.Contains(command.Options, option => HasAlias(option, "--emit-dsse", "-d"));
Assert.Contains(command.Options, option => HasAlias(option, "--signing-key"));
Assert.Contains(command.Options, option => HasAlias(option, "--format", "-f"));
Assert.Contains(command.Options, option => HasAlias(option, "--platform", "-p"));
Assert.Contains(command.Options, option => HasAlias(option, "--include-unchanged"));
Assert.Contains(command.Options, option => HasAlias(option, "--sections"));
Assert.Contains(command.Options, option => HasAlias(option, "--registry-auth"));
Assert.Contains(command.Options, option => HasAlias(option, "--timeout"));
Assert.Contains(command.Options, option => HasAlias(option, "--verbose", "-v"));
}
[Fact]
public void BuildDiffCommand_RequiresBaseAndTarget()
{
var command = BuildDiffCommand();
var baseOption = FindOption(command, "--base");
var targetOption = FindOption(command, "--target");
Assert.NotNull(baseOption);
Assert.NotNull(targetOption);
Assert.True(baseOption!.IsRequired);
Assert.True(targetOption!.IsRequired);
}
[Fact]
public void DiffCommand_ParsesMinimalArgs()
{
var root = BuildRoot(out _);
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2");
Assert.Empty(result.Errors);
}
[Fact]
public void DiffCommand_FailsWhenBaseMissing()
{
var root = BuildRoot(out _);
var result = root.Parse("scan diff --target registry.example.com/app:2");
Assert.NotEmpty(result.Errors);
}
[Fact]
public void DiffCommand_ParsesSectionsValues()
{
var root = BuildRoot(out var diffCommand);
var result = root.Parse("scan diff --base registry.example.com/app:1 --target registry.example.com/app:2 --sections .text,.rodata --sections .data");
Assert.Empty(result.Errors);
var sectionsOption = diffCommand.Options
.OfType<Option<string[]>>()
.Single(option => HasAlias(option, "--sections"));
var values = result.GetValueForOption(sectionsOption);
Assert.Contains(".text,.rodata", values);
Assert.Contains(".data", values);
Assert.True(sectionsOption.AllowMultipleArgumentsPerToken);
}
private Command BuildDiffCommand()
{
return BinaryDiffCommandGroup.BuildDiffCommand(_services, _verboseOption, _cancellationToken);
}
private RootCommand BuildRoot(out Command diffCommand)
{
diffCommand = BuildDiffCommand();
var scan = new Command("scan", "Scanner operations")
{
diffCommand
};
return new RootCommand { scan };
}
private static Option? FindOption(Command command, string alias)
{
return command.Options.FirstOrDefault(option =>
option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
option.Aliases.Contains(alias));
}
private static bool HasAlias(Option option, params string[] aliases)
{
foreach (var alias in aliases)
{
if (option.Name.Equals(alias, StringComparison.OrdinalIgnoreCase) ||
option.Name.Equals(alias.TrimStart('-'), StringComparison.OrdinalIgnoreCase) ||
option.Aliases.Contains(alias))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,165 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Attestor.StandardPredicates.BinaryDiff;
using StellaOps.Cli.Commands.Scan;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
public sealed class BinaryDiffRendererTests
{
private static readonly DateTimeOffset FixedTimestamp =
new(2026, 1, 13, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task RenderJson_WritesCanonicalOutput()
{
var renderer = new BinaryDiffRenderer();
var result = CreateResult();
var first = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Json);
var second = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Json);
Assert.Equal(first, second);
using var document = JsonDocument.Parse(first);
Assert.Equal("1.0.0", document.RootElement.GetProperty("schemaVersion").GetString());
Assert.Equal("elf", document.RootElement.GetProperty("analysisMode").GetString());
Assert.Equal("/usr/bin/app", document.RootElement.GetProperty("findings")[0].GetProperty("path").GetString());
}
[Fact]
public async Task RenderTable_IncludesFindingsAndSummary()
{
var renderer = new BinaryDiffRenderer();
var result = CreateResult();
var output = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Table);
Assert.Contains("Binary Diff:", output);
Assert.Contains("Analysis Mode: ELF section hashes", output);
Assert.Contains("/usr/bin/app", output);
Assert.Contains("Summary:", output);
}
[Fact]
public async Task RenderSummary_ReportsTotals()
{
var renderer = new BinaryDiffRenderer();
var result = CreateResult();
var output = await RenderAsync(renderer, result, BinaryDiffOutputFormat.Summary);
Assert.Contains("Binary Diff Summary", output);
Assert.Contains("Binaries: 2 total, 1 modified, 1 unchanged", output);
Assert.Contains("Added: 0, Removed: 0", output);
}
private static async Task<string> RenderAsync(
BinaryDiffRenderer renderer,
BinaryDiffResult result,
BinaryDiffOutputFormat format)
{
using var writer = new StringWriter();
await renderer.RenderAsync(result, format, writer, CancellationToken.None);
return writer.ToString();
}
private static BinaryDiffResult CreateResult()
{
var verdicts = ImmutableDictionary.CreateRange(
StringComparer.Ordinal,
new[]
{
new KeyValuePair<string, int>("unknown", 1)
});
var findings = ImmutableArray.Create(
new BinaryDiffFinding
{
Path = "/usr/bin/app",
ChangeType = ChangeType.Modified,
BinaryFormat = BinaryFormat.Elf,
LayerDigest = "sha256:layer",
SectionDeltas = ImmutableArray.Create(
new SectionDelta
{
Section = ".text",
Status = SectionStatus.Modified,
BaseSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
TargetSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
SizeDelta = 32
}),
Confidence = 0.75,
Verdict = StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict.Unknown
},
new BinaryDiffFinding
{
Path = "/usr/lib/libfoo.so",
ChangeType = ChangeType.Unchanged,
BinaryFormat = BinaryFormat.Elf,
LayerDigest = "sha256:layer",
SectionDeltas = ImmutableArray<SectionDelta>.Empty,
Confidence = 1.0,
Verdict = StellaOps.Attestor.StandardPredicates.BinaryDiff.Verdict.Vanilla
});
var summary = new BinaryDiffSummary
{
TotalBinaries = 2,
Modified = 1,
Added = 0,
Removed = 0,
Unchanged = 1,
Verdicts = verdicts
};
return new BinaryDiffResult
{
Base = new BinaryDiffImageReference
{
Reference = "registry.example.com/app:1",
Digest = "sha256:base",
ManifestDigest = "sha256:manifest-base",
Platform = new BinaryDiffPlatform
{
Os = "linux",
Architecture = "amd64"
}
},
Target = new BinaryDiffImageReference
{
Reference = "registry.example.com/app:2",
Digest = "sha256:target",
ManifestDigest = "sha256:manifest-target",
Platform = new BinaryDiffPlatform
{
Os = "linux",
Architecture = "amd64"
}
},
Platform = new BinaryDiffPlatform
{
Os = "linux",
Architecture = "amd64"
},
Mode = BinaryDiffMode.Elf,
Findings = findings,
Summary = summary,
Metadata = new BinaryDiffMetadata
{
ToolVersion = "test",
AnalysisTimestamp = FixedTimestamp,
TotalBinaries = summary.TotalBinaries,
ModifiedBinaries = summary.Modified,
AnalyzedSections = ImmutableArray<string>.Empty
},
Predicate = null,
BaseReference = null,
TargetReference = null
};
}
}

View File

@@ -0,0 +1,321 @@
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;
}
}

View File

@@ -77,10 +77,42 @@ public sealed class DoctorCommandGroupTests
listCommand!.Description.Should().Contain("List");
}
[Fact]
public void BuildDoctorCommand_HasFixSubcommand()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
// Assert
var fixCommand = command.Subcommands.FirstOrDefault(c => c.Name == "fix");
fixCommand.Should().NotBeNull();
fixCommand!.Description.Should().Contain("fix");
}
#endregion
#region Run Subcommand Options Tests
[Fact]
public void RootCommand_HasFormatOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
// Assert
var formatOption = command.Options.FirstOrDefault(o =>
o.Name == "format" || o.Aliases.Contains("--format") || o.Aliases.Contains("-f"));
formatOption.Should().NotBeNull();
}
[Fact]
public void RunCommand_HasFormatOption()
{
@@ -274,6 +306,44 @@ public sealed class DoctorCommandGroupTests
#endregion
#region Fix Subcommand Options Tests
[Fact]
public void FixCommand_HasFromOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var fixCommand = command.Subcommands.First(c => c.Name == "fix");
// Assert
var fromOption = fixCommand.Options.FirstOrDefault(o =>
o.Name == "from" || o.Name == "--from" || o.Aliases.Contains("--from"));
fromOption.Should().NotBeNull();
}
[Fact]
public void FixCommand_HasApplyOption()
{
// Arrange
var services = CreateTestServices();
var verboseOption = new Option<bool>("--verbose");
// Act
var command = DoctorCommandGroup.BuildDoctorCommand(services, verboseOption, CancellationToken.None);
var fixCommand = command.Subcommands.First(c => c.Name == "fix");
// Assert
var applyOption = fixCommand.Options.FirstOrDefault(o =>
o.Name == "apply" || o.Name == "--apply" || o.Aliases.Contains("--apply"));
applyOption.Should().NotBeNull();
}
#endregion
#region Exit Codes Tests
[Fact]

View File

@@ -0,0 +1,30 @@
using System.CommandLine;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
namespace StellaOps.Cli.Tests.Commands;
public sealed class ImageCommandTests
{
[Fact]
public void Create_ExposesImageInspectCommand()
{
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
var services = new ServiceCollection().BuildServiceProvider();
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
var image = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "image", StringComparison.Ordinal));
var inspect = Assert.Single(image.Subcommands, command => string.Equals(command.Name, "inspect", StringComparison.Ordinal));
Assert.Contains(inspect.Options, option => option.Name == "--resolve-index" || option.Aliases.Contains("--resolve-index"));
Assert.Contains(inspect.Options, option => option.Name == "--print-layers" || option.Aliases.Contains("--print-layers"));
Assert.Contains(inspect.Options, option => option.Name == "--platform" || option.Aliases.Contains("--platform"));
Assert.Contains(inspect.Options, option => option.Name == "--output" || option.Aliases.Contains("--output"));
Assert.Contains(inspect.Options, option => option.Name == "--timeout" || option.Aliases.Contains("--timeout"));
Assert.Contains(inspect.Arguments, argument => string.Equals(argument.Name, "reference", StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,266 @@
using System.Collections.Immutable;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Testing;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.Storage.Oci;
namespace StellaOps.Cli.Tests.Commands;
public sealed class ImageInspectHandlerTests
{
[Fact]
public async Task HandleInspectImageAsync_ValidResult_ReturnsZero()
{
var result = CreateResult();
var provider = BuildServices(new StubInspector(result));
var originalExit = Environment.ExitCode;
try
{
await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/app:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "json",
timeoutSeconds: 60,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, exitCode);
});
Assert.Equal(0, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleInspectImageAsync_NotFound_ReturnsOne()
{
var provider = BuildServices(new StubInspector(null));
var originalExit = Environment.ExitCode;
try
{
await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/missing:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "table",
timeoutSeconds: 60,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(1, exitCode);
});
Assert.Equal(1, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleInspectImageAsync_InvalidReference_ReturnsTwo()
{
var provider = BuildServices(new StubInspector(CreateResult()));
var originalExit = Environment.ExitCode;
try
{
await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
string.Empty,
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "table",
timeoutSeconds: 60,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(2, exitCode);
});
Assert.Equal(2, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleInspectImageAsync_AuthWarning_ReturnsTwo()
{
var result = CreateResult(warnings: ImmutableArray.Create("Manifest GET returned Unauthorized."));
var provider = BuildServices(new StubInspector(result));
var originalExit = Environment.ExitCode;
try
{
await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/app:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "table",
timeoutSeconds: 60,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(2, exitCode);
});
Assert.Equal(2, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleInspectImageAsync_JsonOutput_IsValidJson()
{
var result = CreateResult();
var provider = BuildServices(new StubInspector(result));
var output = await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/app:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "json",
timeoutSeconds: 60,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, exitCode);
});
var action = () => JsonDocument.Parse(output);
action();
}
private static ImageInspectionResult CreateResult(ImmutableArray<string>? warnings = null)
{
var layer = new LayerInfo
{
Order = 0,
Digest = "sha256:layer",
MediaType = "application/vnd.oci.image.layer.v1.tar+gzip",
Size = 100
};
var platform = new PlatformManifest
{
Os = "linux",
Architecture = "amd64",
Variant = null,
OsVersion = null,
ManifestDigest = "sha256:manifest",
ManifestMediaType = OciMediaTypes.ImageManifest,
ConfigDigest = "sha256:config",
Layers = ImmutableArray.Create(layer),
TotalSize = 100
};
return new ImageInspectionResult
{
Reference = "registry.example/demo/app:1.0",
ResolvedDigest = "sha256:manifest",
MediaType = OciMediaTypes.ImageManifest,
IsMultiArch = false,
Platforms = ImmutableArray.Create(platform),
InspectedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
InspectorVersion = "1.0.0",
Registry = "registry.example",
Repository = "demo/app",
Warnings = warnings ?? ImmutableArray<string>.Empty
};
}
private static ServiceProvider BuildServices(IOciImageInspector inspector)
{
OfflineModeGuard.IsOffline = false;
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
services.AddSingleton(new StellaOpsCliOptions());
services.AddSingleton(inspector);
return services.BuildServiceProvider();
}
private static async Task<string> CaptureConsoleAsync(Func<TestConsole, Task> action)
{
var testConsole = new TestConsole();
var originalConsole = AnsiConsole.Console;
var originalOut = Console.Out;
using var writer = new StringWriter();
try
{
AnsiConsole.Console = testConsole;
Console.SetOut(writer);
await action(testConsole).ConfigureAwait(false);
var output = testConsole.Output.ToString();
if (string.IsNullOrEmpty(output))
{
output = writer.ToString();
}
return output;
}
finally
{
Console.SetOut(originalOut);
AnsiConsole.Console = originalConsole;
}
}
private sealed class StubInspector : IOciImageInspector
{
private readonly ImageInspectionResult? _result;
public StubInspector(ImageInspectionResult? result)
{
_result = result;
}
public Task<ImageInspectionResult?> InspectAsync(
string reference,
ImageInspectionOptions? options = null,
CancellationToken cancellationToken = default)
=> Task.FromResult(_result);
}
}

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Testing;
using StellaOps.Attestor.Envelope;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Telemetry;
@@ -105,8 +106,9 @@ public sealed class OfflineCommandHandlersTests
}
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson));
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBase64);
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var pae = DssePreAuthenticationEncoding.Compute("application/vnd.in-toto+json", payloadBytes);
var signature = Convert.ToBase64String(rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
var dssePath = Path.Combine(bundleDir, "statement.dsse.json");
@@ -278,31 +280,6 @@ public sealed class OfflineCommandHandlersTests
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static byte[] BuildDssePae(string payloadType, string payloadBase64)
{
var payloadBytes = Convert.FromBase64String(payloadBase64);
var payloadText = Encoding.UTF8.GetString(payloadBytes);
var parts = new[]
{
"DSSEv1",
payloadType,
payloadText
};
var builder = new StringBuilder();
builder.Append("PAE:");
builder.Append(parts.Length);
foreach (var part in parts)
{
builder.Append(' ');
builder.Append(part.Length);
builder.Append(' ');
builder.Append(part);
}
return Encoding.UTF8.GetBytes(builder.ToString());
}
private static string WrapPem(string label, byte[] derBytes)
{
var base64 = Convert.ToBase64String(derBytes);

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Testing;
using StellaOps.Attestor.Envelope;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Telemetry;
using StellaOps.Cli.Tests.Testing;
@@ -191,8 +192,9 @@ public sealed class VerifyOfflineCommandHandlersTests
predicate = new { }
}, new JsonSerializerOptions(JsonSerializerDefaults.Web));
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
var pae = BuildDssePae("application/vnd.in-toto+json", payloadBase64);
var payloadBytes = Encoding.UTF8.GetBytes(statementJson);
var payloadBase64 = Convert.ToBase64String(payloadBytes);
var pae = DssePreAuthenticationEncoding.Compute("application/vnd.in-toto+json", payloadBytes);
var signature = Convert.ToBase64String(signingKey.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss));
var envelopeJson = JsonSerializer.Serialize(new
@@ -208,31 +210,6 @@ public sealed class VerifyOfflineCommandHandlersTests
await File.WriteAllTextAsync(path, envelopeJson, new UTF8Encoding(false), ct);
}
private static byte[] BuildDssePae(string payloadType, string payloadBase64)
{
var payloadBytes = Convert.FromBase64String(payloadBase64);
var payloadText = Encoding.UTF8.GetString(payloadBytes);
var parts = new[]
{
"DSSEv1",
payloadType,
payloadText
};
var builder = new StringBuilder();
builder.Append("PAE:");
builder.Append(parts.Length);
foreach (var part in parts)
{
builder.Append(' ');
builder.Append(part.Length);
builder.Append(' ');
builder.Append(part);
}
return Encoding.UTF8.GetBytes(builder.ToString());
}
private static async Task WriteCheckpointAsync(string path, ECDsa signingKey, byte[] rootHash, CancellationToken ct)
{
var origin = "rekor.sigstore.dev - 2605736670972794746";
@@ -285,4 +262,3 @@ public sealed class VerifyOfflineCommandHandlersTests
private sealed record CapturedConsoleOutput(string Console, string Plain);
}

View File

@@ -143,6 +143,42 @@ public sealed class VexGenCommandTests
Assert.NotNull(verboseOpt);
}
[Fact]
public void VexGenCommand_HasLinkEvidenceOption()
{
var command = BuildVexGenCommand();
var option = command.Options.FirstOrDefault(o =>
o.Name == "link-evidence" || o.Name == "--link-evidence" || o.Aliases.Contains("--link-evidence"));
Assert.NotNull(option);
Assert.Contains("evidence", option.Description);
}
[Fact]
public void VexGenCommand_HasEvidenceThresholdOption()
{
var command = BuildVexGenCommand();
var option = command.Options.FirstOrDefault(o =>
o.Name == "evidence-threshold" || o.Name == "--evidence-threshold" || o.Aliases.Contains("--evidence-threshold"));
Assert.NotNull(option);
Assert.Contains("confidence", option.Description);
}
[Fact]
public void VexGenCommand_HasShowEvidenceUriOption()
{
var command = BuildVexGenCommand();
var option = command.Options.FirstOrDefault(o =>
o.Name == "show-evidence-uri" || o.Name == "--show-evidence-uri" || o.Aliases.Contains("--show-evidence-uri"));
Assert.NotNull(option);
Assert.Contains("URI", option.Description);
}
[Fact]
public void VexGenCommand_AllOptionsAreConfigured()
{
@@ -150,7 +186,8 @@ public sealed class VexGenCommandTests
var command = BuildVexGenCommand();
var expectedOptions = new[]
{
"from-drift", "image", "baseline", "output", "format", "status", "verbose"
"from-drift", "image", "baseline", "output", "format", "status", "link-evidence",
"evidence-threshold", "show-evidence-uri", "verbose"
};
// Act - normalize all option names by stripping leading dashes

View File

@@ -0,0 +1,151 @@
using System.Collections.Immutable;
using StellaOps.Cli.Commands;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Evidence;
using Xunit;
namespace StellaOps.Cli.Tests.Commands;
[Trait("Category", "Unit")]
public sealed class VexGenEvidenceTests
{
[Fact]
public async Task AttachEvidenceLinksAsync_AddsEvidenceWhenAvailable()
{
var statement = new OpenVexStatement
{
Id = "vex:stmt:1",
Status = "not_affected",
Timestamp = "2026-01-13T12:00:00Z",
Products =
[
new OpenVexProduct
{
Id = "sha256:demo",
Identifiers = new OpenVexIdentifiers { Facet = "facet-a" }
}
],
Justification = "facet drift authorization",
ActionStatement = "Review required"
};
var document = new OpenVexDocument
{
Context = "https://openvex.dev/ns",
Id = "https://stellaops.io/vex/vex:doc:1",
Author = "StellaOps CLI",
Timestamp = "2026-01-13T12:00:00Z",
Version = 1,
Statements = ImmutableArray.Create(statement)
};
var linker = new TestEvidenceLinker("vex:stmt:1", 0.95);
var result = await VexGenCommandGroup.AttachEvidenceLinksAsync(
document,
linker,
0.8,
CancellationToken.None);
Assert.Single(result.Document.Statements);
Assert.NotNull(result.Document.Statements[0].Evidence);
Assert.Single(result.Summaries);
Assert.Equal("binarydiff", result.Document.Statements[0].Evidence!.Type);
}
[Fact]
public async Task AttachEvidenceLinksAsync_SkipsBelowThreshold()
{
var statement = new OpenVexStatement
{
Id = "vex:stmt:2",
Status = "not_affected",
Timestamp = "2026-01-13T12:00:00Z",
Products =
[
new OpenVexProduct
{
Id = "sha256:demo",
Identifiers = new OpenVexIdentifiers { Facet = "facet-b" }
}
],
Justification = "facet drift authorization",
ActionStatement = "Review required"
};
var document = new OpenVexDocument
{
Context = "https://openvex.dev/ns",
Id = "https://stellaops.io/vex/vex:doc:2",
Author = "StellaOps CLI",
Timestamp = "2026-01-13T12:00:00Z",
Version = 1,
Statements = ImmutableArray.Create(statement)
};
var linker = new TestEvidenceLinker("vex:stmt:2", 0.4);
var result = await VexGenCommandGroup.AttachEvidenceLinksAsync(
document,
linker,
0.8,
CancellationToken.None);
Assert.Null(result.Document.Statements[0].Evidence);
Assert.Empty(result.Summaries);
}
private sealed class TestEvidenceLinker : IVexEvidenceLinker
{
private readonly string _entryId;
private readonly double _confidence;
public TestEvidenceLinker(string entryId, double confidence)
{
_entryId = entryId;
_confidence = confidence;
}
public Task<VexEvidenceLink> LinkAsync(string vexEntryId, EvidenceSource source, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<VexEvidenceLinkSet> GetLinksAsync(string vexEntryId, CancellationToken cancellationToken = default)
{
if (!string.Equals(vexEntryId, _entryId, StringComparison.Ordinal))
{
return Task.FromResult(new VexEvidenceLinkSet
{
VexEntryId = vexEntryId,
Links = ImmutableArray<VexEvidenceLink>.Empty
});
}
var link = new VexEvidenceLink
{
LinkId = "vexlink:test",
VexEntryId = vexEntryId,
EvidenceType = EvidenceType.BinaryDiff,
EvidenceUri = "oci://registry/evidence@sha256:abc",
EnvelopeDigest = "sha256:abc",
PredicateType = "stellaops.binarydiff.v1",
Confidence = _confidence,
Justification = VexJustification.CodeNotReachable,
EvidenceCreatedAt = DateTimeOffset.UtcNow,
LinkedAt = DateTimeOffset.UtcNow,
SignatureValidated = false
};
return Task.FromResult(new VexEvidenceLinkSet
{
VexEntryId = vexEntryId,
Links = ImmutableArray.Create(link)
});
}
public Task<ImmutableArray<VexEvidenceLink>> AutoLinkFromBinaryDiffAsync(
StellaOps.Attestor.StandardPredicates.BinaryDiff.BinaryDiffPredicate diff,
string dsseEnvelopeUri,
CancellationToken cancellationToken = default)
=> Task.FromResult(ImmutableArray<VexEvidenceLink>.Empty);
}
}

View File

@@ -0,0 +1,198 @@
using System.Collections.Immutable;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Testing;
using StellaOps.Cli.Commands;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.Storage.Oci;
using Xunit;
namespace StellaOps.Cli.Tests.GoldenOutput;
[Trait("Category", "Unit")]
[Trait("Category", "GoldenOutput")]
public sealed class ImageInspectGoldenOutputTests
{
[Fact]
public async Task ImageInspect_TableOutput_IsDeterministic()
{
var provider = BuildServices(new StubInspector(CreateResult()));
var output1 = await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/app:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "table",
timeoutSeconds: 60,
verbose: true,
cancellationToken: CancellationToken.None);
exitCode.Should().Be(0);
});
var output2 = await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/app:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "table",
timeoutSeconds: 60,
verbose: true,
cancellationToken: CancellationToken.None);
exitCode.Should().Be(0);
});
output1.Should().Be(output2);
output1.Should().Contain("Image:");
output1.Should().Contain("Resolved Digest:");
output1.Should().Contain("Layers");
}
[Fact]
public async Task ImageInspect_JsonOutput_IsDeterministic()
{
var provider = BuildServices(new StubInspector(CreateResult()));
var output1 = await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/app:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "json",
timeoutSeconds: 60,
verbose: false,
cancellationToken: CancellationToken.None);
exitCode.Should().Be(0);
});
var output2 = await CaptureConsoleAsync(async _ =>
{
var exitCode = await CommandHandlers.HandleInspectImageAsync(
provider,
"registry.example/demo/app:1.0",
resolveIndex: true,
printLayers: true,
platformFilter: null,
output: "json",
timeoutSeconds: 60,
verbose: false,
cancellationToken: CancellationToken.None);
exitCode.Should().Be(0);
});
output1.Should().Be(output2);
output1.Should().Contain("\"reference\"");
output1.Should().Contain("\"platforms\"");
}
private static ImageInspectionResult CreateResult()
{
var layer = new LayerInfo
{
Order = 0,
Digest = "sha256:layer",
MediaType = "application/vnd.oci.image.layer.v1.tar+gzip",
Size = 100
};
var platform = new PlatformManifest
{
Os = "linux",
Architecture = "amd64",
Variant = null,
OsVersion = null,
ManifestDigest = "sha256:manifest",
ManifestMediaType = OciMediaTypes.ImageManifest,
ConfigDigest = "sha256:config",
Layers = ImmutableArray.Create(layer),
TotalSize = 100
};
return new ImageInspectionResult
{
Reference = "registry.example/demo/app:1.0",
ResolvedDigest = "sha256:manifest",
MediaType = OciMediaTypes.ImageManifest,
IsMultiArch = false,
Platforms = ImmutableArray.Create(platform),
InspectedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
InspectorVersion = "1.0.0",
Registry = "registry.example",
Repository = "demo/app",
Warnings = ImmutableArray.Create("Manifest HEAD returned NotFound.")
};
}
private static ServiceProvider BuildServices(IOciImageInspector inspector)
{
OfflineModeGuard.IsOffline = false;
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
services.AddSingleton(new StellaOpsCliOptions());
services.AddSingleton(inspector);
return services.BuildServiceProvider();
}
private static async Task<string> CaptureConsoleAsync(Func<TestConsole, Task> action)
{
var testConsole = new TestConsole();
var originalConsole = AnsiConsole.Console;
var originalOut = Console.Out;
using var writer = new StringWriter();
try
{
AnsiConsole.Console = testConsole;
Console.SetOut(writer);
await action(testConsole).ConfigureAwait(false);
var output = testConsole.Output.ToString();
if (string.IsNullOrEmpty(output))
{
output = writer.ToString();
}
return output;
}
finally
{
Console.SetOut(originalOut);
AnsiConsole.Console = originalConsole;
}
}
private sealed class StubInspector : IOciImageInspector
{
private readonly ImageInspectionResult _result;
public StubInspector(ImageInspectionResult result)
{
_result = result;
}
public Task<ImageInspectionResult?> InspectAsync(
string reference,
ImageInspectionOptions? options = null,
CancellationToken cancellationToken = default)
=> Task.FromResult<ImageInspectionResult?>(_result);
}
}

View File

@@ -0,0 +1,193 @@
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.TestKit;
using Xunit;
namespace StellaOps.Cli.Tests.Integration;
[Trait("Category", TestCategories.Integration)]
public sealed class BinaryDiffIntegrationTests
{
[Fact]
public async Task ComputeDiffAsync_WithElfFixtures_ProducesModifiedFinding()
{
var baseRef = "registry.example.com/app:1";
var targetRef = "registry.example.com/app:2";
var datasetRoot = Path.Combine(FindRepositoryRoot(), "src", "Scanner", "__Tests", "__Datasets", "elf-section-hashes");
var baseElf = File.ReadAllBytes(Path.Combine(datasetRoot, "minimal-amd64.elf"));
var targetElf = File.ReadAllBytes(Path.Combine(datasetRoot, "standard-amd64.elf"));
var baseLayer = CreateLayer(("usr/bin/app", baseElf));
var targetLayer = CreateLayer(("usr/bin/app", targetElf));
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 extractor = new ElfSectionHashExtractor(
TimeProvider.System,
Options.Create(new ElfSectionHashOptions()));
var service = new BinaryDiffService(
registry,
extractor,
Options.Create(new BinaryDiffOptions { ToolVersion = "test" }),
TimeProvider.System,
NullLogger<BinaryDiffService>.Instance);
var result = await service.ComputeDiffAsync(
new BinaryDiffRequest
{
BaseImageRef = baseRef,
TargetImageRef = targetRef,
Mode = BinaryDiffMode.Elf,
Platform = new BinaryDiffPlatform { Os = "linux", Architecture = "amd64" }
},
null,
CancellationToken.None);
Assert.Equal(1, result.Summary.TotalBinaries);
var finding = Assert.Single(result.Findings);
Assert.Equal(ChangeType.Modified, finding.ChangeType);
Assert.Equal("/usr/bin/app", finding.Path);
Assert.NotEmpty(finding.SectionDeltas);
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (directory.GetDirectories("src").Length > 0 &&
directory.GetDirectories("docs").Length > 0)
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Repository root not found.");
}
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 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}");
}
}
}

View File

@@ -26,6 +26,7 @@
<ItemGroup>
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj" />
<ProjectReference Include="../../../Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/StellaOps.Scanner.Storage.Oci.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Aoc/StellaOps.Cli.Plugins.Aoc.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Symbols/StellaOps.Cli.Plugins.Symbols.csproj" />

View File

@@ -8,3 +8,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0143-M | DONE | Revalidated 2026-01-06. |
| AUDIT-0143-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0143-A | DONE | Waived (test project; revalidated 2026-01-06). |
| CLI-IMAGE-TESTS-0001 | DONE | SPRINT_20260113_002_002 - Unit tests for image inspect. |
| CLI-IMAGE-GOLDEN-0001 | DONE | SPRINT_20260113_002_002 - Golden output determinism tests. |
| CLI-DIFF-TESTS-0001 | DONE | SPRINT_20260113_001_003 - Binary diff unit tests added. |
| CLI-DIFF-INTEGRATION-0001 | DONE | SPRINT_20260113_001_003 - Binary diff integration test added. |
| CLI-VEX-EVIDENCE-TESTS-0001 | DONE | SPRINT_20260113_003_002 - VEX evidence tests. |