audit, advisories and doctors/setup work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user