Add Authority Advisory AI and API Lifecycle Configuration
- Introduced AuthorityAdvisoryAiOptions and related classes for managing advisory AI configurations, including remote inference options and tenant-specific settings. - Added AuthorityApiLifecycleOptions to control API lifecycle settings, including legacy OAuth endpoint configurations. - Implemented validation and normalization methods for both advisory AI and API lifecycle options to ensure proper configuration. - Created AuthorityNotificationsOptions and its related classes for managing notification settings, including ack tokens, webhooks, and escalation options. - Developed IssuerDirectoryClient and related models for interacting with the issuer directory service, including caching mechanisms and HTTP client configurations. - Added support for dependency injection through ServiceCollectionExtensions for the Issuer Directory Client. - Updated project file to include necessary package references for the new Issuer Directory Client library.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "rust",
|
||||
"componentKey": "bin::sha256:10f3c03766e4403be40add0467a2b2d07fd7006e4b8515ab88740ffa327ea775",
|
||||
"purl": null,
|
||||
"name": "opaque_bin",
|
||||
"version": null,
|
||||
"type": "bin",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"binary.path": "usr/local/bin/opaque_bin",
|
||||
"binary.sha256": "10f3c03766e4403be40add0467a2b2d07fd7006e4b8515ab88740ffa327ea775",
|
||||
"provenance": "binary"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "file",
|
||||
"source": "binary",
|
||||
"locator": "usr/local/bin/opaque_bin",
|
||||
"value": null,
|
||||
"sha256": "10f3c03766e4403be40add0467a2b2d07fd7006e4b8515ab88740ffa327ea775"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"detectedCrates": [
|
||||
{
|
||||
"name": "serde",
|
||||
"note": "Binary symbol scan matched only serde"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"analyzerId": "rust",
|
||||
"componentKey": "rust::heuristic::reqwest::usr/local/bin/heuristic_app",
|
||||
"name": "reqwest",
|
||||
"type": "cargo",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"binary.paths": "usr/local/bin/heuristic_app",
|
||||
"binary.sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b",
|
||||
"crate": "reqwest",
|
||||
"provenance": "heuristic"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "rust.heuristic",
|
||||
"locator": "usr/local/bin/heuristic_app",
|
||||
"value": "reqwest",
|
||||
"sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "rust",
|
||||
"componentKey": "rust::heuristic::serde::usr/local/bin/heuristic_app",
|
||||
"name": "serde",
|
||||
"type": "cargo",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"binary.paths": "usr/local/bin/heuristic_app",
|
||||
"binary.sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b",
|
||||
"crate": "serde",
|
||||
"provenance": "heuristic"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "rust.heuristic",
|
||||
"locator": "usr/local/bin/heuristic_app",
|
||||
"value": "serde",
|
||||
"sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"analyzerId": "rust",
|
||||
"componentKey": "rust::heuristic::tokio::usr/local/bin/heuristic_app",
|
||||
"name": "tokio",
|
||||
"type": "cargo",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"binary.paths": "usr/local/bin/heuristic_app",
|
||||
"binary.sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b",
|
||||
"crate": "tokio",
|
||||
"provenance": "heuristic"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "derived",
|
||||
"source": "rust.heuristic",
|
||||
"locator": "usr/local/bin/heuristic_app",
|
||||
"value": "tokio",
|
||||
"sha256": "4caf60c501a594b5d4b8d909b3e91fccc4447692b9e144f322a333255909310b"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Rust;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Tests.Rust;
|
||||
|
||||
public sealed class RustHeuristicCoverageComparisonTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task HeuristicCoverageExceedsCompetitorBaselineAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "heuristics");
|
||||
var baselinePath = Path.Combine(fixturePath, "competitor-baseline.json");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new RustLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var output = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var ours = JsonDocument.Parse(output);
|
||||
var heuristicNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var element in ours.RootElement.EnumerateArray())
|
||||
{
|
||||
if (!element.TryGetProperty("metadata", out var metadata) || metadata.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var provenance = metadata.EnumerateObject()
|
||||
.FirstOrDefault(p => string.Equals(p.Name, "provenance", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (provenance.Value.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(provenance.Value.GetString(), "heuristic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (element.TryGetProperty("name", out var nameProperty) && nameProperty.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = nameProperty.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
heuristicNames.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var competitor = JsonDocument.Parse(await File.ReadAllTextAsync(baselinePath, cancellationToken));
|
||||
var competitorNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (competitor.RootElement.TryGetProperty("detectedCrates", out var detectedCrates) && detectedCrates.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var entry in detectedCrates.EnumerateArray())
|
||||
{
|
||||
if (entry.ValueKind == JsonValueKind.Object && entry.TryGetProperty("name", out var nameProperty) && nameProperty.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var name = nameProperty.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
competitorNames.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotEmpty(competitorNames);
|
||||
Assert.True(heuristicNames.IsSupersetOf(competitorNames));
|
||||
|
||||
var improvement = (double)heuristicNames.Count / competitorNames.Count;
|
||||
Assert.True(improvement >= 1.15, $"Expected at least 15% improvement; got {improvement:P2} ({heuristicNames.Count} vs {competitorNames.Count}).");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Rust;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Rust;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.Harness;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Tests.TestUtilities;
|
||||
|
||||
@@ -35,25 +36,86 @@ public sealed class RustLanguageAnalyzerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnalyzerIsThreadSafeUnderConcurrencyAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "simple");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new RustLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var workers = Math.Max(Environment.ProcessorCount, 4);
|
||||
var tasks = Enumerable.Range(0, workers)
|
||||
.Select(_ => LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken));
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
var baseline = results[0];
|
||||
foreach (var result in results)
|
||||
{
|
||||
Assert.Equal(baseline, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
public async Task AnalyzerIsThreadSafeUnderConcurrencyAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "simple");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new RustLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var workers = Math.Max(Environment.ProcessorCount, 4);
|
||||
var tasks = Enumerable.Range(0, workers)
|
||||
.Select(_ => LanguageAnalyzerTestHarness.RunToJsonAsync(fixturePath, analyzers, cancellationToken));
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
var baseline = results[0];
|
||||
foreach (var result in results)
|
||||
{
|
||||
Assert.Equal(baseline, result);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeuristicFixtureProducesExpectedOutputAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "heuristics");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
var usageHints = new LanguageUsageHints(new[]
|
||||
{
|
||||
Path.Combine(fixturePath, "usr/local/bin/heuristic_app")
|
||||
});
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new RustLanguageAnalyzer()
|
||||
};
|
||||
|
||||
await LanguageAnalyzerTestHarness.AssertDeterministicAsync(
|
||||
fixturePath,
|
||||
goldenPath,
|
||||
analyzers,
|
||||
cancellationToken,
|
||||
usageHints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FallbackFixtureProducesExpectedOutputAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "rust", "fallback");
|
||||
var goldenPath = Path.Combine(fixturePath, "expected.json");
|
||||
var usageHints = new LanguageUsageHints(new[]
|
||||
{
|
||||
Path.Combine(fixturePath, "usr/local/bin/opaque_bin")
|
||||
});
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new RustLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var actualJson = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken,
|
||||
usageHints);
|
||||
|
||||
var repeat = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken,
|
||||
usageHints);
|
||||
Assert.Equal(actualJson, repeat);
|
||||
|
||||
var expectedJson = await File.ReadAllTextAsync(goldenPath, cancellationToken);
|
||||
var actualNode = JsonNode.Parse(actualJson);
|
||||
var expectedNode = JsonNode.Parse(expectedJson);
|
||||
Assert.True(
|
||||
JsonNode.DeepEquals(expectedNode, actualNode),
|
||||
"Fallback fixture output does not match expected snapshot.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,64 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests;
|
||||
|
||||
public sealed class EntryTraceAnalyzerTests
|
||||
{
|
||||
private static EntryTraceAnalyzer CreateAnalyzer()
|
||||
{
|
||||
var options = Options.Create(new EntryTraceAnalyzerOptions
|
||||
{
|
||||
MaxDepth = 32,
|
||||
FollowRunParts = true
|
||||
});
|
||||
return new EntryTraceAnalyzer(options, new EntryTraceMetrics(), NullLogger<EntryTraceAnalyzer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FollowsShellIncludeAndPythonModule()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/entrypoint.sh", """
|
||||
#!/bin/sh
|
||||
source /opt/setup.sh
|
||||
exec python -m app.main --flag
|
||||
""");
|
||||
fs.AddFile("/opt/setup.sh", """
|
||||
#!/bin/sh
|
||||
run-parts /opt/setup.d
|
||||
""");
|
||||
fs.AddDirectory("/opt/setup.d");
|
||||
fs.AddFile("/opt/setup.d/001-node.sh", """
|
||||
#!/bin/sh
|
||||
exec node /app/server.js
|
||||
""");
|
||||
fs.AddFile("/opt/setup.d/010-java.sh", """
|
||||
#!/bin/sh
|
||||
java -jar /app/app.jar
|
||||
""");
|
||||
fs.AddFile("/usr/bin/python", "#!/usr/bin/env python3\n", executable: true);
|
||||
fs.AddFile("/usr/bin/node", "#!/usr/bin/env node\n", executable: true);
|
||||
fs.AddFile("/usr/bin/java", "", executable: true);
|
||||
fs.AddFile("/app/server.js", "console.log('hello');", executable: true);
|
||||
fs.AddFile("/app/app.jar", string.Empty, executable: true);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace.Diagnostics;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests;
|
||||
|
||||
public sealed class EntryTraceAnalyzerTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public EntryTraceAnalyzerTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
private static EntryTraceAnalyzer CreateAnalyzer()
|
||||
{
|
||||
var options = Options.Create(new EntryTraceAnalyzerOptions
|
||||
{
|
||||
MaxDepth = 32,
|
||||
FollowRunParts = true
|
||||
});
|
||||
return new EntryTraceAnalyzer(options, new EntryTraceMetrics(), NullLogger<EntryTraceAnalyzer>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FollowsShellIncludeAndPythonModule()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/entrypoint.sh", """
|
||||
#!/bin/sh
|
||||
source /opt/setup.sh
|
||||
exec python -m app.main --flag
|
||||
""");
|
||||
fs.AddFile("/opt/setup.sh", """
|
||||
#!/bin/sh
|
||||
run-parts /opt/setup.d
|
||||
""");
|
||||
fs.AddDirectory("/opt/setup.d");
|
||||
fs.AddFile("/opt/setup.d/001-node.sh", """
|
||||
#!/bin/sh
|
||||
exec node /app/server.js
|
||||
""");
|
||||
fs.AddFile("/opt/setup.d/010-java.sh", """
|
||||
#!/bin/sh
|
||||
java -jar /app/app.jar
|
||||
""");
|
||||
fs.AddFile("/usr/bin/python", "#!/usr/bin/env python3\n", executable: true);
|
||||
fs.AddFile("/usr/bin/node", "#!/usr/bin/env node\n", executable: true);
|
||||
fs.AddFile("/usr/bin/java", "", executable: true);
|
||||
fs.AddFile("/app/server.js", "console.log('hello');", executable: true);
|
||||
fs.AddFile("/app/app.jar", string.Empty, executable: true);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var context = new EntryTraceContext(
|
||||
fs,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
@@ -56,36 +68,40 @@ public sealed class EntryTraceAnalyzerTests
|
||||
"sha256:image",
|
||||
"scan-entrytrace-1",
|
||||
NullLogger.Instance);
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
|
||||
var result = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
|
||||
Assert.Empty(result.Diagnostics);
|
||||
|
||||
var nodeNames = result.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray();
|
||||
Assert.Contains((EntryTraceNodeKind.Command, "/entrypoint.sh"), nodeNames);
|
||||
Assert.Contains((EntryTraceNodeKind.Include, "/opt/setup.sh"), nodeNames);
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "python");
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "node");
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "java");
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.RunPartsDirectory && tuple.DisplayName == "/opt/setup.d");
|
||||
|
||||
Assert.Contains(result.Edges, edge => edge.Relationship == "python-module" && edge.Metadata is { } metadata && metadata.TryGetValue("module", out var module) && module == "app.main");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RecordsDiagnosticsForMissingInclude()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/entrypoint.sh", """
|
||||
#!/bin/sh
|
||||
source /missing/setup.sh
|
||||
exec /bin/true
|
||||
""");
|
||||
fs.AddFile("/bin/true", string.Empty, executable: true);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
|
||||
var result = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
if (result.Outcome != EntryTraceOutcome.Resolved)
|
||||
{
|
||||
var details = string.Join(", ", result.Diagnostics.Select(d => d.Severity + ":" + d.Reason));
|
||||
throw new XunitException("Unexpected outcome: " + result.Outcome + "; diagnostics=" + details);
|
||||
}
|
||||
Assert.Empty(result.Diagnostics);
|
||||
|
||||
var nodeNames = result.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray();
|
||||
Assert.Contains((EntryTraceNodeKind.Command, "/entrypoint.sh"), nodeNames);
|
||||
Assert.Contains((EntryTraceNodeKind.Include, "/opt/setup.sh"), nodeNames);
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "python");
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "node");
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.Command && tuple.DisplayName == "java");
|
||||
Assert.Contains(nodeNames, tuple => tuple.Kind == EntryTraceNodeKind.RunPartsDirectory && tuple.DisplayName == "/opt/setup.d");
|
||||
|
||||
Assert.Contains(result.Edges, edge => edge.Relationship == "python-module" && edge.Metadata is { } metadata && metadata.TryGetValue("module", out var module) && module == "app.main");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_RecordsDiagnosticsForMissingInclude()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/entrypoint.sh", """
|
||||
#!/bin/sh
|
||||
source /missing/setup.sh
|
||||
exec /bin/true
|
||||
""");
|
||||
fs.AddFile("/bin/true", string.Empty, executable: true);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var context = new EntryTraceContext(
|
||||
fs,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
@@ -95,27 +111,27 @@ public sealed class EntryTraceAnalyzerTests
|
||||
"sha256:image",
|
||||
"scan-entrytrace-2",
|
||||
NullLogger.Instance);
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
|
||||
var result = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
|
||||
Assert.Single(result.Diagnostics);
|
||||
Assert.Equal(EntryTraceUnknownReason.MissingFile, result.Diagnostics[0].Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_IsDeterministic()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/entrypoint.sh", """
|
||||
#!/bin/sh
|
||||
exec node /app/index.js
|
||||
""");
|
||||
fs.AddFile("/usr/bin/node", string.Empty, executable: true);
|
||||
fs.AddFile("/app/index.js", "console.log('deterministic');", executable: true);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
|
||||
var result = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
|
||||
Assert.Single(result.Diagnostics);
|
||||
Assert.Equal(EntryTraceUnknownReason.MissingFile, result.Diagnostics[0].Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_IsDeterministic()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/entrypoint.sh", """
|
||||
#!/bin/sh
|
||||
exec node /app/index.js
|
||||
""");
|
||||
fs.AddFile("/usr/bin/node", string.Empty, executable: true);
|
||||
fs.AddFile("/app/index.js", "console.log('deterministic');", executable: true);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var context = new EntryTraceContext(
|
||||
fs,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
@@ -125,13 +141,13 @@ public sealed class EntryTraceAnalyzerTests
|
||||
"sha256:image",
|
||||
"scan-entrytrace-3",
|
||||
NullLogger.Instance);
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
|
||||
var first = await analyzer.ResolveAsync(spec, context);
|
||||
var second = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
Assert.Equal(first.Outcome, second.Outcome);
|
||||
Assert.Equal(first.Diagnostics, second.Diagnostics);
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "/entrypoint.sh" }, Array.Empty<string>());
|
||||
var first = await analyzer.ResolveAsync(spec, context);
|
||||
var second = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
Assert.Equal(first.Outcome, second.Outcome);
|
||||
Assert.Equal(first.Diagnostics, second.Diagnostics);
|
||||
Assert.Equal(first.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray(), second.Nodes.Select(n => (n.Kind, n.DisplayName)).ToArray());
|
||||
Assert.Equal(first.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray(),
|
||||
second.Edges.Select(e => (e.FromNodeId, e.ToNodeId, e.Relationship)).ToArray());
|
||||
@@ -144,6 +160,7 @@ public sealed class EntryTraceAnalyzerTests
|
||||
fs.AddFile("/windows/system32/cmd.exe", string.Empty, executable: true);
|
||||
fs.AddFile("/scripts/start.bat", "@echo off\r\necho start\r\n", executable: true);
|
||||
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var context = new EntryTraceContext(
|
||||
fs,
|
||||
@@ -158,8 +175,230 @@ public sealed class EntryTraceAnalyzerTests
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "cmd.exe", "/c", "/scripts/start.bat" }, Array.Empty<string>());
|
||||
var result = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
Assert.Equal(EntryTraceOutcome.PartiallyResolved, result.Outcome);
|
||||
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
|
||||
Assert.Contains(result.Nodes, node => node.Kind == EntryTraceNodeKind.Script && node.DisplayName == "/scripts/start.bat");
|
||||
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.UnsupportedSyntax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ClassifiesGoBinaryWithPlan()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
var goBinary = CreateGoBinary();
|
||||
fs.AddBinaryFile("/usr/local/bin/goapp", goBinary, executable: true);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var context = new EntryTraceContext(
|
||||
fs,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
ImmutableArray.Create("/usr/local/bin"),
|
||||
"/",
|
||||
"root",
|
||||
"sha256:go-image",
|
||||
"scan-go",
|
||||
NullLogger.Instance);
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "/usr/local/bin/goapp", "--serve" }, null);
|
||||
var result = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
|
||||
|
||||
var terminal = Assert.Single(result.Terminals);
|
||||
Assert.Equal("/usr/local/bin/goapp", terminal.Path);
|
||||
Assert.Equal(EntryTraceTerminalType.Native, terminal.Type);
|
||||
Assert.Equal("go", terminal.Runtime);
|
||||
Assert.True(terminal.Confidence >= 70d);
|
||||
Assert.False(terminal.Arguments.IsDefault);
|
||||
Assert.Equal(2, terminal.Arguments.Length);
|
||||
Assert.Equal("--serve", terminal.Arguments[1]);
|
||||
Assert.Contains("runtime", terminal.Evidence.Keys);
|
||||
|
||||
var plan = Assert.Single(result.Plans);
|
||||
Assert.Equal(terminal.Path, plan.TerminalPath);
|
||||
Assert.Equal("go", plan.Runtime);
|
||||
Assert.Equal(terminal.Confidence, plan.Confidence);
|
||||
Assert.Equal(terminal.Arguments, plan.Command);
|
||||
Assert.Equal("/", plan.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ExtractsJarManifestEvidence()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
var jarBytes = CreateJarWithManifest("com.example.Main");
|
||||
fs.AddBinaryFile("/app/example.jar", jarBytes, executable: true);
|
||||
fs.AddFile("/usr/bin/java", string.Empty, executable: true);
|
||||
Assert.True(fs.TryResolveExecutable("/app/example.jar", Array.Empty<string>(), out _));
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var context = new EntryTraceContext(
|
||||
fs,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
ImmutableArray.Create("/usr/bin"),
|
||||
"/",
|
||||
"root",
|
||||
"sha256:java-image",
|
||||
"scan-jar",
|
||||
NullLogger.Instance);
|
||||
|
||||
var spec = EntrypointSpecification.FromExecForm(new[] { "java", "-jar", "/app/example.jar" }, null);
|
||||
var result = await analyzer.ResolveAsync(spec, context);
|
||||
|
||||
if (result.Terminals.Length == 0)
|
||||
{
|
||||
var nodeSummary = string.Join(", ", result.Nodes.Select(n => $"{n.Kind}:{n.DisplayName}"));
|
||||
var diagSummary = string.Join(", ", result.Diagnostics.Select(d => d.Reason.ToString()));
|
||||
throw new XunitException($"Terminals empty; nodes={nodeSummary}; diags={diagSummary}");
|
||||
}
|
||||
|
||||
var terminal = Assert.Single(result.Terminals);
|
||||
Assert.Equal(EntryTraceTerminalType.Managed, terminal.Type);
|
||||
Assert.Equal("java", terminal.Runtime);
|
||||
Assert.True(terminal.Evidence.ContainsKey("jar.manifest"));
|
||||
Assert.Equal("com.example.Main", terminal.Evidence["jar.main-class"]);
|
||||
Assert.Contains("-jar", terminal.Arguments);
|
||||
|
||||
var plan = Assert.Single(result.Plans);
|
||||
Assert.Equal(terminal.Evidence, plan.Evidence);
|
||||
Assert.Equal("java", plan.Runtime);
|
||||
Assert.Equal(terminal.Confidence, plan.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_UsesHistoryCandidateWhenEntrypointMissing()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddBinaryFile("/usr/bin/node", CreateGoBinary(), executable: true);
|
||||
|
||||
var config = new OciImageConfig
|
||||
{
|
||||
History = ImmutableArray.Create(new OciHistoryEntry("/bin/sh -c #(nop) CMD [\"/usr/bin/node\",\"/app/server.js\"]", false))
|
||||
};
|
||||
|
||||
var options = new EntryTraceAnalyzerOptions();
|
||||
var imageContext = EntryTraceImageContextFactory.Create(
|
||||
config,
|
||||
fs,
|
||||
options,
|
||||
"sha256:image-history",
|
||||
"scan-history",
|
||||
NullLogger.Instance);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
|
||||
|
||||
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
|
||||
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromHistory);
|
||||
var terminal = Assert.Single(result.Terminals);
|
||||
Assert.Equal("/usr/bin/node", terminal.Path);
|
||||
Assert.Contains("/usr/bin/node", terminal.Arguments[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_DiscoversSupervisorCommand()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddBinaryFile("/usr/bin/gunicorn", CreateGoBinary(), executable: true);
|
||||
fs.AddDirectory("/etc/supervisor");
|
||||
fs.AddFile("/etc/supervisor/app.conf", """
|
||||
[program:web]
|
||||
command=gunicorn app:app
|
||||
""");
|
||||
|
||||
var config = new OciImageConfig();
|
||||
var options = new EntryTraceAnalyzerOptions();
|
||||
var imageContext = EntryTraceImageContextFactory.Create(
|
||||
config,
|
||||
fs,
|
||||
options,
|
||||
"sha256:image-supervisor",
|
||||
"scan-supervisor",
|
||||
NullLogger.Instance);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
|
||||
|
||||
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
|
||||
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromSupervisor);
|
||||
var terminal = Assert.Single(result.Terminals);
|
||||
Assert.Equal("/usr/bin/gunicorn", terminal.Path);
|
||||
Assert.Contains("gunicorn", terminal.Arguments[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_DiscoversServiceRunScript()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddDirectory("/etc");
|
||||
fs.AddDirectory("/etc/services.d");
|
||||
fs.AddDirectory("/etc/services.d/web");
|
||||
fs.AddBinaryFile("/usr/bin/python", CreateGoBinary(), executable: true);
|
||||
fs.AddFile("/etc/services.d/web/run", """
|
||||
#!/bin/sh
|
||||
/usr/bin/python -m app.main
|
||||
""");
|
||||
|
||||
var config = new OciImageConfig();
|
||||
var options = new EntryTraceAnalyzerOptions();
|
||||
var imageContext = EntryTraceImageContextFactory.Create(
|
||||
config,
|
||||
fs,
|
||||
options,
|
||||
"sha256:image-service",
|
||||
"scan-service",
|
||||
NullLogger.Instance);
|
||||
|
||||
var candidates = imageContext.Context.Candidates;
|
||||
var candidateSummary = string.Join(", ", candidates.Select(c => $"{c.Source}:{string.Join(' ', c.Command)}"));
|
||||
Assert.True(
|
||||
candidates.Length > 0,
|
||||
$"Candidates discovered: {candidateSummary}");
|
||||
var inferred = Assert.Single(candidates);
|
||||
Assert.Equal("service-directory", inferred.Source);
|
||||
Assert.Equal("/etc/services.d/web/run", inferred.Command[0]);
|
||||
Assert.NotNull(inferred.Evidence);
|
||||
Assert.True(inferred.Evidence!.Metadata?.ContainsKey("service_dir") ?? false);
|
||||
Assert.Equal("/etc/services.d/web", inferred.Evidence!.Metadata!["service_dir"]);
|
||||
|
||||
var analyzer = CreateAnalyzer();
|
||||
var result = await analyzer.ResolveAsync(imageContext.Entrypoint, imageContext.Context);
|
||||
|
||||
var nonInfoDiagnostics = result.Diagnostics.Where(d => d.Severity != EntryTraceDiagnosticSeverity.Info).ToArray();
|
||||
Assert.True(nonInfoDiagnostics.Length == 0, string.Join(", ", nonInfoDiagnostics.Select(d => d.Severity + ":" + d.Reason)));
|
||||
Assert.Equal(EntryTraceOutcome.Resolved, result.Outcome);
|
||||
Assert.Contains(result.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.InferredEntrypointFromServices);
|
||||
var terminal = Assert.Single(result.Terminals);
|
||||
Assert.Equal("/usr/bin/python", terminal.Path);
|
||||
Assert.Contains("/usr/bin/python", terminal.Arguments[0]);
|
||||
Assert.Contains(result.Nodes, node => node.Kind == EntryTraceNodeKind.Script && node.DisplayName == "/etc/services.d/web/run");
|
||||
Assert.Equal(EntryTraceTerminalType.Native, terminal.Type);
|
||||
}
|
||||
|
||||
private static byte[] CreateGoBinary()
|
||||
{
|
||||
var buffer = new byte[256];
|
||||
buffer[0] = 0x7F;
|
||||
buffer[1] = (byte)'E';
|
||||
buffer[2] = (byte)'L';
|
||||
buffer[3] = (byte)'F';
|
||||
|
||||
var signature = Encoding.ASCII.GetBytes("Go build ID");
|
||||
signature.CopyTo(buffer, 32);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static byte[] CreateJarWithManifest(string mainClass)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
var manifest = archive.CreateEntry("META-INF/MANIFEST.MF");
|
||||
using var writer = new StreamWriter(manifest.Open(), Encoding.UTF8);
|
||||
writer.WriteLine("Manifest-Version: 1.0");
|
||||
writer.WriteLine($"Main-Class: {mainClass}");
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests;
|
||||
|
||||
public sealed class EntryTraceNdjsonWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_ProducesDeterministicNdjsonLines()
|
||||
{
|
||||
var (graph, metadata) = CreateSampleGraph();
|
||||
|
||||
var lines = EntryTraceNdjsonWriter.Serialize(graph, metadata);
|
||||
|
||||
Assert.Equal(6, lines.Length);
|
||||
|
||||
var entryJson = Parse(lines[0]);
|
||||
Assert.Equal("entrytrace.entry", entryJson.GetProperty("type").GetString());
|
||||
Assert.Equal(metadata.ScanId, entryJson.GetProperty("scan_id").GetString());
|
||||
Assert.Equal("resolved", entryJson.GetProperty("outcome").GetString());
|
||||
Assert.Equal(1, entryJson.GetProperty("nodes").GetInt32());
|
||||
Assert.Equal(1, entryJson.GetProperty("edges").GetInt32());
|
||||
Assert.Equal(1, entryJson.GetProperty("targets").GetInt32());
|
||||
Assert.Equal(1, entryJson.GetProperty("warnings").GetInt32());
|
||||
|
||||
var nodeJson = Parse(lines[1]);
|
||||
Assert.Equal("entrytrace.node", nodeJson.GetProperty("type").GetString());
|
||||
Assert.Equal("gosu", nodeJson.GetProperty("display_name").GetString());
|
||||
Assert.Equal("command", nodeJson.GetProperty("kind").GetString());
|
||||
Assert.Equal("user-switch", nodeJson.GetProperty("metadata").GetProperty("wrapper.category").GetString());
|
||||
|
||||
var edgeJson = Parse(lines[2]);
|
||||
Assert.Equal("entrytrace.edge", edgeJson.GetProperty("type").GetString());
|
||||
Assert.Equal("wraps", edgeJson.GetProperty("relationship").GetString());
|
||||
|
||||
var targetJson = Parse(lines[3]);
|
||||
Assert.Equal("entrytrace.target", targetJson.GetProperty("type").GetString());
|
||||
Assert.Equal("python", targetJson.GetProperty("runtime").GetString());
|
||||
Assert.Equal("scanner", targetJson.GetProperty("user").GetString());
|
||||
Assert.Equal(87.5, targetJson.GetProperty("confidence").GetDouble());
|
||||
Assert.Equal("medium", targetJson.GetProperty("confidence_level").GetString());
|
||||
|
||||
var warningJson = Parse(lines[4]);
|
||||
Assert.Equal("entrytrace.warning", warningJson.GetProperty("type").GetString());
|
||||
Assert.Equal("dynamicevaluation", warningJson.GetProperty("reason").GetString());
|
||||
|
||||
var capabilityJson = Parse(lines[5]);
|
||||
Assert.Equal("entrytrace.capability", capabilityJson.GetProperty("type").GetString());
|
||||
Assert.Equal("user-switch", capabilityJson.GetProperty("category").GetString());
|
||||
Assert.Equal("gosu", capabilityJson.GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_ProducesStableSha256Hash()
|
||||
{
|
||||
var (graph, metadata) = CreateSampleGraph();
|
||||
|
||||
var lines = EntryTraceNdjsonWriter.Serialize(graph, metadata);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>(128);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line);
|
||||
buffer.Write(bytes);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
var actual = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
const string ExpectedHash = "37444d7f68ceafd3e974c10ce5e78c973874d4a0abe73660f8ad204151b93c62";
|
||||
Assert.Equal(ExpectedHash, actual);
|
||||
}
|
||||
|
||||
private static (EntryTraceGraph Graph, EntryTraceNdjsonMetadata Metadata) CreateSampleGraph()
|
||||
{
|
||||
var nodeMetadata = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
nodeMetadata["wrapper.category"] = "user-switch";
|
||||
nodeMetadata["wrapper.name"] = "gosu";
|
||||
|
||||
var node = new EntryTraceNode(
|
||||
1,
|
||||
EntryTraceNodeKind.Command,
|
||||
"gosu",
|
||||
ImmutableArray.Create("gosu", "scanner", "python", "/app/main.py"),
|
||||
EntryTraceInterpreterKind.None,
|
||||
new EntryTraceEvidence("/usr/bin/gosu", "sha256:layer-a", "path", ImmutableDictionary<string, string>.Empty),
|
||||
new EntryTraceSpan("/scripts/entrypoint.sh", 1, 0, 1, 10),
|
||||
nodeMetadata.ToImmutable());
|
||||
|
||||
var edge = new EntryTraceEdge(1, 2, "wraps", null);
|
||||
|
||||
var plan = new EntryTracePlan(
|
||||
ImmutableArray.Create("/app/main.py", "--serve"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/app",
|
||||
"scanner",
|
||||
"/app/main.py",
|
||||
EntryTraceTerminalType.Native,
|
||||
"python",
|
||||
87.5,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var terminal = new EntryTraceTerminal(
|
||||
"/app/main.py",
|
||||
EntryTraceTerminalType.Native,
|
||||
"python",
|
||||
87.5,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"scanner",
|
||||
"/app",
|
||||
ImmutableArray.Create("/app/main.py", "--serve"));
|
||||
|
||||
var diagnostic = new EntryTraceDiagnostic(
|
||||
EntryTraceDiagnosticSeverity.Warning,
|
||||
EntryTraceUnknownReason.DynamicEvaluation,
|
||||
"Command 'eval' prevents static resolution.",
|
||||
new EntryTraceSpan("/scripts/entrypoint.sh", 5, 0, 5, 30),
|
||||
"/scripts/entrypoint.sh");
|
||||
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray.Create(node),
|
||||
ImmutableArray.Create(edge),
|
||||
ImmutableArray.Create(diagnostic),
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
|
||||
var metadata = new EntryTraceNdjsonMetadata(
|
||||
"scan-entrytrace-1",
|
||||
"sha256:image",
|
||||
DateTimeOffset.Parse("2025-01-01T00:00:00Z", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
|
||||
Source: "worker");
|
||||
|
||||
return (graph, metadata);
|
||||
}
|
||||
|
||||
private static JsonElement Parse(string ndjsonLine)
|
||||
{
|
||||
Assert.EndsWith("\n", ndjsonLine, StringComparison.Ordinal);
|
||||
var json = ndjsonLine.TrimEnd('\n');
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
using System;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests;
|
||||
|
||||
@@ -17,15 +18,21 @@ public sealed class LayeredRootFileSystemTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDirectories_HandlesWhiteoutsAndResolution()
|
||||
{
|
||||
var layer1 = CreateLayerDirectory("layer1");
|
||||
var layer2 = CreateLayerDirectory("layer2");
|
||||
|
||||
var usrBin1 = Path.Combine(layer1, "usr", "bin");
|
||||
Directory.CreateDirectory(usrBin1);
|
||||
var entrypointPath = Path.Combine(usrBin1, "entrypoint.sh");
|
||||
File.WriteAllText(entrypointPath, "#!/bin/sh\necho layer1\n");
|
||||
public void FromDirectories_HandlesWhiteoutsAndResolution()
|
||||
{
|
||||
var layer1 = CreateLayerDirectory("layer1");
|
||||
var layer2 = CreateLayerDirectory("layer2");
|
||||
|
||||
var usrBin1 = Path.Combine(layer1, "usr", "bin");
|
||||
Directory.CreateDirectory(usrBin1);
|
||||
var entrypointPath = Path.Combine(usrBin1, "entrypoint.sh");
|
||||
File.WriteAllText(entrypointPath, "#!/bin/sh\necho layer1\n");
|
||||
#if NET8_0_OR_GREATER
|
||||
File.SetUnixFileMode(entrypointPath,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
|
||||
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
|
||||
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
|
||||
#endif
|
||||
|
||||
var optDirectory1 = Path.Combine(layer1, "opt");
|
||||
Directory.CreateDirectory(optDirectory1);
|
||||
@@ -52,9 +59,28 @@ public sealed class LayeredRootFileSystemTests : IDisposable
|
||||
Assert.False(fs.TryReadAllText("/opt/setup.sh", out _, out _));
|
||||
|
||||
var optEntries = fs.EnumerateDirectory("/opt");
|
||||
Assert.DoesNotContain(optEntries, entry => entry.Path.EndsWith("setup.sh", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(optEntries, entry => entry.Path.EndsWith("setup.sh", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadBytes_ReturnsLimitedPreview()
|
||||
{
|
||||
var layer = CreateLayerDirectory("layer-bytes");
|
||||
var usrBin = Path.Combine(layer, "usr", "bin");
|
||||
Directory.CreateDirectory(usrBin);
|
||||
File.WriteAllText(Path.Combine(usrBin, "tool"), "abcdefg");
|
||||
|
||||
var fs = LayeredRootFileSystem.FromDirectories(new[]
|
||||
{
|
||||
new LayeredRootFileSystem.LayerDirectory("sha256:bytes", layer)
|
||||
});
|
||||
|
||||
Assert.True(fs.TryReadBytes("/usr/bin/tool", 4, out var descriptor, out var preview));
|
||||
Assert.Equal("/usr/bin/tool", descriptor.Path);
|
||||
Assert.Equal(4, preview.Length);
|
||||
Assert.Equal("abcd", Encoding.UTF8.GetString(preview.Span));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromArchives_ResolvesSymlinkAndWhiteout()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Runtime;
|
||||
|
||||
public sealed class EntryTraceRuntimeReconcilerTests
|
||||
{
|
||||
private static readonly ImmutableDictionary<string, string> EmptyDictionary = ImmutableDictionary<string, string>.Empty;
|
||||
private static readonly ImmutableArray<string> EmptyArray = ImmutableArray<string>.Empty;
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_MatchesRuntimeTerminal()
|
||||
{
|
||||
var reconciler = new EntryTraceRuntimeReconciler();
|
||||
var graph = CreateGraph("/usr/local/bin/app");
|
||||
|
||||
var procGraph = ProcGraphBuilder.Build(new FakeProvider(new[]
|
||||
{
|
||||
CreateProcess(1, 0, "/sbin/tini", "tini", 100),
|
||||
CreateProcess(5, 1, "/usr/local/bin/app", "app", 200),
|
||||
}));
|
||||
|
||||
var reconciled = reconciler.Reconcile(graph, procGraph);
|
||||
|
||||
Assert.Equal(95d, reconciled.Plans[0].Confidence);
|
||||
Assert.Contains(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_FlagsMismatch_WhenDifferentExecutable()
|
||||
{
|
||||
var reconciler = new EntryTraceRuntimeReconciler();
|
||||
var graph = CreateGraph("/usr/local/bin/app");
|
||||
|
||||
var procGraph = ProcGraphBuilder.Build(new FakeProvider(new[]
|
||||
{
|
||||
CreateProcess(1, 0, "/sbin/init", "init", 100),
|
||||
CreateProcess(2, 1, "/usr/bin/other", "other", 200),
|
||||
}));
|
||||
|
||||
var reconciled = reconciler.Reconcile(graph, procGraph);
|
||||
|
||||
Assert.Equal(60d, reconciled.Plans[0].Confidence);
|
||||
Assert.Contains(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reconcile_AddsDiagnostic_WhenSnapshotAbsent()
|
||||
{
|
||||
var reconciler = new EntryTraceRuntimeReconciler();
|
||||
var graph = CreateGraph("/usr/local/bin/app");
|
||||
|
||||
var reconciled = reconciler.Reconcile(graph, procGraph: null);
|
||||
|
||||
Assert.Contains(reconciled.Diagnostics, d => d.Reason == EntryTraceUnknownReason.RuntimeSnapshotUnavailable);
|
||||
}
|
||||
|
||||
private static EntryTraceGraph CreateGraph(string terminalPath)
|
||||
{
|
||||
var plan = new EntryTracePlan(
|
||||
ImmutableArray.Create(terminalPath),
|
||||
EmptyDictionary,
|
||||
"/",
|
||||
"root",
|
||||
terminalPath,
|
||||
EntryTraceTerminalType.Native,
|
||||
Runtime: null,
|
||||
Confidence: 50d,
|
||||
EmptyDictionary);
|
||||
|
||||
var terminal = new EntryTraceTerminal(
|
||||
terminalPath,
|
||||
EntryTraceTerminalType.Native,
|
||||
Runtime: null,
|
||||
Confidence: 50d,
|
||||
EmptyDictionary,
|
||||
"root",
|
||||
"/",
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
return new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
}
|
||||
|
||||
private static ProcProcess CreateProcess(int pid, int parentPid, string executable, string commandName, ulong startTime)
|
||||
{
|
||||
return new ProcProcess(
|
||||
pid,
|
||||
parentPid,
|
||||
executable,
|
||||
ImmutableArray.Create(executable),
|
||||
commandName,
|
||||
startTime);
|
||||
}
|
||||
|
||||
private sealed class FakeProvider : IProcSnapshotProvider
|
||||
{
|
||||
private readonly Dictionary<int, ProcProcess> _processes;
|
||||
|
||||
public FakeProvider(IEnumerable<ProcProcess> processes)
|
||||
{
|
||||
_processes = new Dictionary<int, ProcProcess>();
|
||||
foreach (var process in processes)
|
||||
{
|
||||
_processes[process.Pid] = process;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<int> EnumerateProcessIds() => _processes.Keys;
|
||||
|
||||
public bool TryReadProcess(int pid, out ProcProcess process) => _processes.TryGetValue(pid, out process);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.EntryTrace.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Runtime;
|
||||
|
||||
public sealed class ProcFileSystemSnapshotTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tempDirectory = new();
|
||||
|
||||
[Fact]
|
||||
public void EnumerateProcessIds_ReturnsNumericDirectories()
|
||||
{
|
||||
CreateProcessDirectory(101);
|
||||
CreateProcessDirectory(5);
|
||||
Directory.CreateDirectory(Path.Combine(_tempDirectory.Path, "not-a-pid"));
|
||||
|
||||
var snapshot = new ProcFileSystemSnapshot(_tempDirectory.Path);
|
||||
var pids = snapshot.EnumerateProcessIds();
|
||||
|
||||
Assert.Contains(5, pids);
|
||||
Assert.Contains(101, pids);
|
||||
Assert.DoesNotContain(-1, pids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadProcess_ParsesStatAndCmdline()
|
||||
{
|
||||
var pid = 321;
|
||||
var procPath = CreateProcessDirectory(pid);
|
||||
File.WriteAllText(
|
||||
Path.Combine(procPath, "stat"),
|
||||
$"{pid} (bash) S 1 {pid} {pid} 0 -1 4194560 0 0 0 0 0 0 0 20 0 1 0 600 0 0 0 0 0 0 0 0 0 0 0 0");
|
||||
File.WriteAllBytes(
|
||||
Path.Combine(procPath, "cmdline"),
|
||||
Encoding.UTF8.GetBytes("/bin/bash\0-c\0run.sh\0"));
|
||||
|
||||
var snapshot = new ProcFileSystemSnapshot(_tempDirectory.Path);
|
||||
Assert.True(snapshot.TryReadProcess(pid, out var process));
|
||||
Assert.Equal(1, process.ParentPid);
|
||||
Assert.Equal("/bin/bash", process.CommandLine[0]);
|
||||
Assert.Equal("bash", process.CommandName);
|
||||
Assert.Equal((ulong)600, process.StartTimeTicks);
|
||||
}
|
||||
|
||||
private string CreateProcessDirectory(int pid)
|
||||
{
|
||||
var procPath = Path.Combine(_tempDirectory.Path, pid.ToString());
|
||||
Directory.CreateDirectory(procPath);
|
||||
File.WriteAllText(Path.Combine(procPath, "stat"), $"{pid} (init) S 0 0 0 0 -1 4194560 0 0 0 0 0 0 0 20 0 1 0 100 0 0 0 0 0 0 0 0 0 0 0 0");
|
||||
File.WriteAllBytes(Path.Combine(procPath, "cmdline"), Encoding.UTF8.GetBytes($"/proc/{pid}/exe\0"));
|
||||
return procPath;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tempDirectory.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"entrytrace-proc-{Guid.NewGuid():n}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup errors for temp directory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Runtime;
|
||||
|
||||
public sealed class ProcGraphBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ReturnsGraph_WithOrderedChildren()
|
||||
{
|
||||
var processes = new[]
|
||||
{
|
||||
CreateProcess(1, 0, "/sbin/init", "init", startTime: 100),
|
||||
CreateProcess(20, 1, "/usr/bin/httpd", "httpd", startTime: 400),
|
||||
CreateProcess(10, 1, "/usr/local/bin/app", "app", startTime: 300)
|
||||
};
|
||||
|
||||
var graph = ProcGraphBuilder.Build(new FakeProcSnapshotProvider(processes));
|
||||
|
||||
Assert.NotNull(graph);
|
||||
Assert.Equal(1, graph!.RootPid);
|
||||
Assert.Equal(3, graph.Processes.Count);
|
||||
Assert.True(graph.Children.TryGetValue(1, out var children));
|
||||
Assert.Equal(new[] { 10, 20 }, children);
|
||||
}
|
||||
|
||||
private static ProcProcess CreateProcess(int pid, int parentPid, string executable, string commandName, ulong startTime)
|
||||
{
|
||||
return new ProcProcess(
|
||||
pid,
|
||||
parentPid,
|
||||
executable,
|
||||
ImmutableArray.Create(executable),
|
||||
commandName,
|
||||
startTime);
|
||||
}
|
||||
|
||||
private sealed class FakeProcSnapshotProvider : IProcSnapshotProvider
|
||||
{
|
||||
private readonly Dictionary<int, ProcProcess> _processes;
|
||||
|
||||
public FakeProcSnapshotProvider(IEnumerable<ProcProcess> processes)
|
||||
{
|
||||
_processes = new Dictionary<int, ProcProcess>();
|
||||
foreach (var process in processes)
|
||||
{
|
||||
_processes[process.Pid] = process;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<int> EnumerateProcessIds() => _processes.Keys;
|
||||
|
||||
public bool TryReadProcess(int pid, out ProcProcess process) => _processes.TryGetValue(pid, out process);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests;
|
||||
|
||||
@@ -15,22 +16,35 @@ internal sealed class TestRootFileSystem : IRootFileSystem
|
||||
_directories.Add("/");
|
||||
}
|
||||
|
||||
public void AddFile(string path, string content, bool executable = true, string? layer = "sha256:layer-a")
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
var directory = Path.GetDirectoryName(normalized);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
_directories.Add(directory!);
|
||||
}
|
||||
|
||||
_entries[normalized] = new FileEntry(normalized, content, executable, layer, IsDirectory: false);
|
||||
}
|
||||
public void AddFile(string path, string content, bool executable = true, string? layer = "sha256:layer-a")
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
var directory = Path.GetDirectoryName(normalized);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
EnsureDirectoryChain(directory!);
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
_entries[normalized] = FileEntry.Create(normalized, bytes, content, executable, layer, isDirectory: false);
|
||||
}
|
||||
|
||||
public void AddBinaryFile(string path, byte[] content, bool executable = true, string? layer = "sha256:layer-a")
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
var directory = Path.GetDirectoryName(normalized);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
EnsureDirectoryChain(directory!);
|
||||
}
|
||||
|
||||
_entries[normalized] = FileEntry.Create(normalized, content, text: null, executable, layer, isDirectory: false);
|
||||
}
|
||||
|
||||
public void AddDirectory(string path)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
_directories.Add(normalized);
|
||||
var normalized = Normalize(path);
|
||||
EnsureDirectoryChain(normalized);
|
||||
}
|
||||
|
||||
public bool TryResolveExecutable(string name, IReadOnlyList<string> searchPaths, out RootFileDescriptor descriptor)
|
||||
@@ -62,37 +76,66 @@ internal sealed class TestRootFileSystem : IRootFileSystem
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
if (_entries.TryGetValue(normalized, out var file))
|
||||
{
|
||||
descriptor = file.ToDescriptor();
|
||||
content = file.Content;
|
||||
return true;
|
||||
}
|
||||
public bool TryReadAllText(string path, out RootFileDescriptor descriptor, out string content)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
if (_entries.TryGetValue(normalized, out var file) && file.TryReadText(out content))
|
||||
{
|
||||
descriptor = file.ToDescriptor();
|
||||
return true;
|
||||
}
|
||||
|
||||
descriptor = null!;
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryReadBytes(string path, int maxBytes, out RootFileDescriptor descriptor, out ReadOnlyMemory<byte> content)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
if (_entries.TryGetValue(normalized, out var file) && file.TryReadBytes(maxBytes, out content))
|
||||
{
|
||||
descriptor = file.ToDescriptor();
|
||||
return true;
|
||||
}
|
||||
|
||||
descriptor = null!;
|
||||
content = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
descriptor = null!;
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
var builder = ImmutableArray.CreateBuilder<RootFileDescriptor>();
|
||||
|
||||
foreach (var file in _entries.Values)
|
||||
{
|
||||
var directory = Normalize(Path.GetDirectoryName(file.Path) ?? "/");
|
||||
if (string.Equals(directory, normalized, StringComparison.Ordinal))
|
||||
{
|
||||
builder.Add(file.ToDescriptor());
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
public ImmutableArray<RootFileDescriptor> EnumerateDirectory(string path)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
var entries = new List<RootFileDescriptor>();
|
||||
|
||||
foreach (var directory in _directories)
|
||||
{
|
||||
if (string.Equals(directory, normalized, StringComparison.Ordinal) ||
|
||||
string.Equals(directory, "/", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parent = Normalize(Path.GetDirectoryName(directory) ?? "/");
|
||||
if (string.Equals(parent, normalized, StringComparison.Ordinal))
|
||||
{
|
||||
entries.Add(new RootFileDescriptor(directory, null, false, true, null));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in _entries.Values)
|
||||
{
|
||||
var directory = Normalize(Path.GetDirectoryName(file.Path) ?? "/");
|
||||
if (string.Equals(directory, normalized, StringComparison.Ordinal))
|
||||
{
|
||||
entries.Add(file.ToDescriptor());
|
||||
}
|
||||
}
|
||||
|
||||
entries.Sort(static (left, right) => string.CompareOrdinal(left.Path, right.Path));
|
||||
return entries.ToImmutableArray();
|
||||
}
|
||||
|
||||
public bool DirectoryExists(string path)
|
||||
{
|
||||
@@ -100,7 +143,7 @@ internal sealed class TestRootFileSystem : IRootFileSystem
|
||||
return _directories.Contains(normalized);
|
||||
}
|
||||
|
||||
private static string Combine(string prefix, string name)
|
||||
private static string Combine(string prefix, string name)
|
||||
{
|
||||
var normalizedPrefix = Normalize(prefix);
|
||||
if (normalizedPrefix == "/")
|
||||
@@ -111,7 +154,7 @@ internal sealed class TestRootFileSystem : IRootFileSystem
|
||||
return Normalize($"{normalizedPrefix}/{name}");
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
@@ -144,37 +187,117 @@ internal sealed class TestRootFileSystem : IRootFileSystem
|
||||
parts.Add(part);
|
||||
}
|
||||
|
||||
return "/" + string.Join('/', parts);
|
||||
}
|
||||
return "/" + string.Join('/', parts);
|
||||
}
|
||||
|
||||
private void EnsureDirectoryChain(string path)
|
||||
{
|
||||
var normalized = Normalize(path);
|
||||
if (string.Equals(normalized, "/", StringComparison.Ordinal))
|
||||
{
|
||||
_directories.Add(normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var current = "/";
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
current = current == "/" ? $"/{segment}" : $"{current}/{segment}";
|
||||
_directories.Add(current);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record FileEntry(string Path, string Content, bool IsExecutable, string? Layer, bool IsDirectory)
|
||||
{
|
||||
public RootFileDescriptor ToDescriptor()
|
||||
{
|
||||
var shebang = ExtractShebang(Content);
|
||||
return new RootFileDescriptor(Path, Layer, IsExecutable, IsDirectory, shebang);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractShebang(string content)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var reader = new StringReader(content);
|
||||
var firstLine = reader.ReadLine();
|
||||
if (firstLine is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!firstLine.StartsWith("#!", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstLine[2..].Trim();
|
||||
}
|
||||
private sealed class FileEntry
|
||||
{
|
||||
private readonly byte[] _content;
|
||||
private readonly string? _text;
|
||||
|
||||
private FileEntry(string path, byte[] content, string? text, bool isExecutable, string? layer, bool isDirectory)
|
||||
{
|
||||
Path = path;
|
||||
_content = content;
|
||||
_text = text;
|
||||
IsExecutable = isExecutable;
|
||||
Layer = layer;
|
||||
IsDirectory = isDirectory;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public bool IsExecutable { get; }
|
||||
|
||||
public string? Layer { get; }
|
||||
|
||||
public bool IsDirectory { get; }
|
||||
|
||||
public static FileEntry Create(string path, byte[] content, string? text, bool isExecutable, string? layer, bool isDirectory)
|
||||
=> new(path, content, text, isExecutable, layer, isDirectory);
|
||||
|
||||
public RootFileDescriptor ToDescriptor()
|
||||
{
|
||||
var shebang = ExtractShebang(_text, _content);
|
||||
return new RootFileDescriptor(Path, Layer, IsExecutable, IsDirectory, shebang);
|
||||
}
|
||||
|
||||
public bool TryReadText(out string content)
|
||||
{
|
||||
if (IsDirectory || _text is null)
|
||||
{
|
||||
content = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
content = _text;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReadBytes(int maxBytes, out ReadOnlyMemory<byte> content)
|
||||
{
|
||||
if (IsDirectory)
|
||||
{
|
||||
content = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = Math.Min(maxBytes, _content.Length);
|
||||
content = new ReadOnlyMemory<byte>(_content, 0, length);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractShebang(string? textContent, byte[] binaryContent)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(textContent))
|
||||
{
|
||||
using var reader = new StringReader(textContent);
|
||||
var firstLine = reader.ReadLine();
|
||||
if (firstLine is not null && firstLine.StartsWith("#!", StringComparison.Ordinal))
|
||||
{
|
||||
return firstLine[2..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (binaryContent.Length >= 2 && binaryContent[0] == '#' && binaryContent[1] == '!')
|
||||
{
|
||||
var end = Array.IndexOf(binaryContent, (byte)'\n');
|
||||
if (end < 0)
|
||||
{
|
||||
end = binaryContent.Length;
|
||||
}
|
||||
|
||||
var shebangLength = Math.Max(0, end - 2);
|
||||
while (shebangLength > 0 && (binaryContent[2 + shebangLength - 1] == '\r' || binaryContent[2 + shebangLength - 1] == 0))
|
||||
{
|
||||
shebangLength--;
|
||||
}
|
||||
|
||||
if (shebangLength > 0)
|
||||
{
|
||||
return Encoding.UTF8.GetString(binaryContent, 2, shebangLength).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Serialization;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Storage.Services;
|
||||
using Xunit;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
public sealed class EntryTraceResultStoreTests : IClassFixture<ScannerMongoFixture>
|
||||
{
|
||||
private readonly ScannerMongoFixture _fixture;
|
||||
|
||||
public EntryTraceResultStoreTests(ScannerMongoFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_ThrowsWhenResultNull()
|
||||
{
|
||||
var store = CreateStore();
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
||||
{
|
||||
EntryTraceResult? result = null;
|
||||
await store.StoreAsync(result!, CancellationToken.None);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullWhenMissing()
|
||||
{
|
||||
await ClearCollectionAsync();
|
||||
var store = CreateStore();
|
||||
|
||||
var result = await store.GetAsync("scan-missing", CancellationToken.None);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_RoundTripsResult()
|
||||
{
|
||||
await ClearCollectionAsync();
|
||||
var store = CreateStore();
|
||||
|
||||
var scanId = $"scan-{Guid.NewGuid():n}";
|
||||
var generatedAt = new DateTimeOffset(2025, 11, 2, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
var node = new EntryTraceNode(
|
||||
1,
|
||||
EntryTraceNodeKind.Command,
|
||||
"python",
|
||||
ImmutableArray.Create("python", "/app/main.py"),
|
||||
EntryTraceInterpreterKind.None,
|
||||
new EntryTraceEvidence("/usr/bin/python", "sha256:layer-a", "path", ImmutableDictionary<string, string>.Empty),
|
||||
new EntryTraceSpan("/app/start.sh", 1, 0, 1, 14),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var plan = new EntryTracePlan(
|
||||
ImmutableArray.Create("/app/main.py"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/workspace",
|
||||
"scanner",
|
||||
"/app/main.py",
|
||||
EntryTraceTerminalType.Native,
|
||||
"python",
|
||||
0.95,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var terminal = new EntryTraceTerminal(
|
||||
"/app/main.py",
|
||||
EntryTraceTerminalType.Native,
|
||||
"python",
|
||||
0.95,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"scanner",
|
||||
"/workspace",
|
||||
ImmutableArray.Create("/app/main.py"));
|
||||
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray.Create(node),
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
|
||||
var ndjson = EntryTraceNdjsonWriter.Serialize(
|
||||
graph,
|
||||
new EntryTraceNdjsonMetadata(scanId, "sha256:image", generatedAt, Source: "storage.tests"));
|
||||
|
||||
var result = new EntryTraceResult(scanId, "sha256:image", generatedAt, graph, ndjson);
|
||||
|
||||
await store.StoreAsync(result, CancellationToken.None);
|
||||
|
||||
var stored = await store.GetAsync(scanId, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(result.ScanId, stored!.ScanId);
|
||||
Assert.Equal(result.ImageDigest, stored.ImageDigest);
|
||||
Assert.Equal(result.GeneratedAtUtc, stored.GeneratedAtUtc);
|
||||
Assert.Equal(result.Graph, stored.Graph);
|
||||
Assert.Equal(result.Ndjson, stored.Ndjson);
|
||||
}
|
||||
|
||||
private async Task ClearCollectionAsync()
|
||||
{
|
||||
var provider = CreateProvider();
|
||||
await provider.EntryTrace.DeleteManyAsync(_ => true);
|
||||
}
|
||||
|
||||
private EntryTraceResultStore CreateStore()
|
||||
{
|
||||
var provider = CreateProvider();
|
||||
var repository = new EntryTraceRepository(provider);
|
||||
return new EntryTraceResultStore(repository);
|
||||
}
|
||||
|
||||
private MongoCollectionProvider CreateProvider()
|
||||
{
|
||||
var options = Options.Create(new ScannerStorageOptions
|
||||
{
|
||||
Mongo = new MongoOptions
|
||||
{
|
||||
ConnectionString = _fixture.Runner.ConnectionString,
|
||||
DatabaseName = _fixture.Database.DatabaseNamespace.DatabaseName,
|
||||
UseMajorityReadConcern = false,
|
||||
UseMajorityWriteConcern = false
|
||||
}
|
||||
});
|
||||
|
||||
return new MongoCollectionProvider(_fixture.Database, options);
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ public sealed class RustFsArtifactObjectStoreTests
|
||||
}
|
||||
|
||||
// Materialize content to ensure downstream callers can inspect it.
|
||||
_ = await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
_ = await request.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
CapturedRequests.Add(new CapturedRequest(request.Method, request.RequestUri!, headerSnapshot));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env.Tests;
|
||||
|
||||
public sealed class SurfaceEnvironmentBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_UsesDefaults_WhenVariablesMissing()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var environment = SurfaceEnvironmentFactory.Create(services, options =>
|
||||
{
|
||||
options.RequireSurfaceEndpoint = false;
|
||||
});
|
||||
|
||||
Assert.Equal("surface-cache", environment.Settings.SurfaceFsBucket);
|
||||
Assert.Equal(4096, environment.Settings.CacheQuotaMegabytes);
|
||||
Assert.False(environment.Settings.PrefetchEnabled);
|
||||
Assert.NotNull(environment.Settings.CacheRoot);
|
||||
Assert.True(environment.Settings.CacheRoot.Exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ReadsEnvironmentVariables_WithPrefixes()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", "custom-bucket");
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", "512");
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
|
||||
|
||||
try
|
||||
{
|
||||
var services = CreateServices();
|
||||
var environment = SurfaceEnvironmentFactory.Create(services);
|
||||
|
||||
Assert.Equal("custom-bucket", environment.Settings.SurfaceFsBucket);
|
||||
Assert.Equal(512, environment.Settings.CacheQuotaMegabytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_Throws_WhenIntegerOutOfRange()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", "1");
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
|
||||
|
||||
try
|
||||
{
|
||||
var services = CreateServices();
|
||||
var exception = Assert.Throws<SurfaceEnvironmentException>(() => SurfaceEnvironmentFactory.Create(services));
|
||||
Assert.Equal("SURFACE_CACHE_QUOTA_MB", exception.Variable);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_CACHE_QUOTA_MB", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static IServiceProvider CreateServices(Action<IServiceCollection>? configure = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder().Build();
|
||||
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddLogging(builder => builder.ClearProviders());
|
||||
|
||||
configure?.Invoke(services);
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Env.Tests;
|
||||
|
||||
public sealed class SurfaceEnvironmentFeatureFlagTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ReturnsFlags_LowerCased()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FEATURES", "Validation,PreWarm , unknown");
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.example.test");
|
||||
try
|
||||
{
|
||||
var services = CreateServices();
|
||||
var environment = SurfaceEnvironmentFactory.Create(services, options =>
|
||||
{
|
||||
options.KnownFeatureFlags.Add("validation");
|
||||
options.KnownFeatureFlags.Add("prewarm");
|
||||
options.RequireSurfaceEndpoint = true;
|
||||
});
|
||||
|
||||
Assert.Contains("validation", environment.Settings.FeatureFlags);
|
||||
Assert.Contains("prewarm", environment.Settings.FeatureFlags);
|
||||
Assert.Contains("unknown", environment.Settings.FeatureFlags);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FEATURES", null);
|
||||
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static IServiceProvider CreateServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddLogging(builder => builder.ClearProviders());
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
|
||||
|
||||
namespace StellaOps.Scanner.Surface.FS.Tests;
|
||||
|
||||
public sealed class FileSurfaceCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_PersistsValue()
|
||||
{
|
||||
var root = Directory.CreateTempSubdirectory();
|
||||
try
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SurfaceCacheOptions { RootDirectory = root.FullName });
|
||||
var cache = new FileSurfaceCache(options, NullLogger<FileSurfaceCache>.Instance);
|
||||
var key = new SurfaceCacheKey("entrytrace", "tenant", "digest");
|
||||
|
||||
var result = await cache.GetOrCreateAsync(
|
||||
key,
|
||||
_ => Task.FromResult(42),
|
||||
Serialize,
|
||||
Deserialize);
|
||||
|
||||
Assert.Equal(42, result);
|
||||
|
||||
var cached = await cache.GetOrCreateAsync(
|
||||
key,
|
||||
_ => Task.FromResult(99),
|
||||
Serialize,
|
||||
Deserialize);
|
||||
|
||||
Assert.Equal(42, cached);
|
||||
}
|
||||
finally
|
||||
{
|
||||
root.Delete(true);
|
||||
}
|
||||
|
||||
static ReadOnlyMemory<byte> Serialize(int value)
|
||||
=> JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
|
||||
static int Deserialize(ReadOnlyMemory<byte> payload)
|
||||
=> JsonSerializer.Deserialize<int>(payload.Span)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.FS/StellaOps.Scanner.Surface.FS.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Tests;
|
||||
|
||||
public sealed class FileSurfaceSecretProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsSecret_FromJson()
|
||||
{
|
||||
var rootDirectory = Directory.CreateTempSubdirectory();
|
||||
var root = rootDirectory.FullName;
|
||||
var request = new SurfaceSecretRequest("tenant", "component", "registry");
|
||||
var path = Path.Combine(root, request.Tenant, request.Component, request.SecretType);
|
||||
Directory.CreateDirectory(path);
|
||||
var payloadPath = Path.Combine(path, "default.json");
|
||||
await File.WriteAllTextAsync(payloadPath, JsonSerializer.Serialize(new
|
||||
{
|
||||
Payload = Convert.ToBase64String(new byte[] { 10, 20, 30 }),
|
||||
Metadata = new Dictionary<string, string> { ["username"] = "demo" }
|
||||
}));
|
||||
|
||||
try
|
||||
{
|
||||
var provider = new FileSurfaceSecretProvider(root);
|
||||
var handle = await provider.GetAsync(request);
|
||||
try
|
||||
{
|
||||
Assert.Equal(new byte[] { 10, 20, 30 }, handle.AsBytes().ToArray());
|
||||
Assert.Equal("demo", handle.Metadata["username"]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
rootDirectory.Delete(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Secrets.Providers;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Tests;
|
||||
|
||||
public sealed class InlineSurfaceSecretProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsSecret_WhenInlineAllowed()
|
||||
{
|
||||
var configuration = new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: true);
|
||||
var provider = new InlineSurfaceSecretProvider(configuration);
|
||||
var request = new SurfaceSecretRequest("tenant", "component", "registry");
|
||||
var key = "SURFACE_SECRET_TENANT_COMPONENT_REGISTRY_DEFAULT";
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, Convert.ToBase64String(new byte[] { 1, 2, 3 }));
|
||||
var handle = await provider.GetAsync(request);
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, handle.AsBytes().ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_Throws_WhenInlineDisallowed()
|
||||
{
|
||||
var configuration = new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: false);
|
||||
var provider = new InlineSurfaceSecretProvider(configuration);
|
||||
var request = new SurfaceSecretRequest("tenant", "component", "registry");
|
||||
await Assert.ThrowsAsync<SurfaceSecretNotFoundException>(async () => await provider.GetAsync(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Secrets.Tests
|
||||
{
|
||||
public sealed class SurfaceSecretsServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddSurfaceSecrets_RegistersProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ISurfaceEnvironment>(_ => new TestSurfaceEnvironment());
|
||||
services.AddLogging(builder => builder.ClearProviders());
|
||||
services.AddSurfaceSecrets();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var secretProvider = provider.GetRequiredService<ISurfaceSecretProvider>();
|
||||
Assert.NotNull(secretProvider);
|
||||
}
|
||||
|
||||
private sealed class TestSurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public SurfaceEnvironmentSettings Settings { get; }
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; }
|
||||
|
||||
public TestSurfaceEnvironment()
|
||||
{
|
||||
Settings = new SurfaceEnvironmentSettings(
|
||||
new Uri("https://surface.example"),
|
||||
"surface",
|
||||
null,
|
||||
new DirectoryInfo(Path.GetTempPath()),
|
||||
1024,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new SurfaceSecretsConfiguration("file", "tenant", Root: Path.GetTempPath(), Namespace: null, FallbackProvider: null, AllowInline: true),
|
||||
"tenant",
|
||||
new SurfaceTlsConfiguration(null, null, null))
|
||||
{
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
RawVariables = new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
|
||||
namespace StellaOps.Scanner.Surface.Validation.Tests;
|
||||
|
||||
public sealed class SurfaceValidatorRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EnsureAsync_Throws_WhenValidationFails()
|
||||
{
|
||||
var services = CreateServices(services =>
|
||||
{
|
||||
services.Configure<SurfaceValidationOptions>(options =>
|
||||
{
|
||||
options.ThrowOnFailure = true;
|
||||
options.ContinueOnError = false;
|
||||
});
|
||||
});
|
||||
|
||||
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
|
||||
var environment = new SurfaceEnvironmentSettings(
|
||||
new Uri("https://surface.invalid"),
|
||||
string.Empty,
|
||||
null,
|
||||
new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString())),
|
||||
0,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new SurfaceSecretsConfiguration("kubernetes", "", null, null, null, false),
|
||||
string.Empty,
|
||||
new SurfaceTlsConfiguration(null, null, null));
|
||||
|
||||
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
|
||||
|
||||
await Assert.ThrowsAsync<SurfaceValidationException>(() => runner.EnsureAsync(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAllAsync_ReturnsSuccess_ForValidConfiguration()
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "stellaops-tests", Guid.NewGuid().ToString()))
|
||||
{
|
||||
Attributes = FileAttributes.Normal
|
||||
};
|
||||
|
||||
var environment = new SurfaceEnvironmentSettings(
|
||||
new Uri("https://surface.example.com"),
|
||||
"surface-cache",
|
||||
null,
|
||||
directory,
|
||||
1024,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new SurfaceSecretsConfiguration("kubernetes", "tenant-a", null, "stellaops", null, false),
|
||||
"tenant-a",
|
||||
new SurfaceTlsConfiguration(null, null, null));
|
||||
|
||||
var services = CreateServices();
|
||||
var runner = services.GetRequiredService<ISurfaceValidatorRunner>();
|
||||
var context = SurfaceValidationContext.Create(services, "TestComponent", environment);
|
||||
|
||||
var result = await runner.RunAllAsync(context);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
private static ServiceProvider CreateServices(Action<IServiceCollection>? configure = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ILogger<LoggingSurfaceValidationReporter>>(_ => NullLogger<LoggingSurfaceValidationReporter>.Instance);
|
||||
services.AddSingleton<ILogger<SurfaceValidatorRunner>>(_ => NullLogger<SurfaceValidatorRunner>.Instance);
|
||||
services.AddOptions();
|
||||
services.AddSurfaceValidation();
|
||||
|
||||
configure?.Invoke(services);
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Serialization;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
@@ -126,14 +132,74 @@ public sealed class ScansEndpointsTests
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
Assert.NotNull(coordinator);
|
||||
Assert.True(coordinator.TokenMatched);
|
||||
Assert.True(coordinator.LastToken.CanBeCanceled);
|
||||
}
|
||||
|
||||
private sealed class RecordingCoordinator : IScanCoordinator
|
||||
{
|
||||
private readonly IHttpContextAccessor accessor;
|
||||
private readonly InMemoryScanCoordinator inner;
|
||||
Assert.True(coordinator.TokenMatched);
|
||||
Assert.True(coordinator.LastToken.CanBeCanceled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EntryTraceEndpointReturnsStoredResult()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
var scanId = $"scan-entrytrace-{Guid.NewGuid():n}";
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(new EntryTracePlan(
|
||||
ImmutableArray.Create("/bin/bash", "-lc", "./start.sh"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/workspace",
|
||||
"root",
|
||||
"/bin/bash",
|
||||
EntryTraceTerminalType.Script,
|
||||
"bash",
|
||||
0.9,
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableArray.Create(new EntryTraceTerminal(
|
||||
"/bin/bash",
|
||||
EntryTraceTerminalType.Script,
|
||||
"bash",
|
||||
0.9,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"root",
|
||||
"/workspace",
|
||||
ImmutableArray<string>.Empty)));
|
||||
|
||||
var ndjson = new List<string> { "{\"kind\":\"entry\"}" };
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<EntryTraceRepository>();
|
||||
await repository.UpsertAsync(new EntryTraceDocument
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = "sha256:entrytrace",
|
||||
GeneratedAtUtc = DateTime.UtcNow,
|
||||
GraphJson = EntryTraceGraphSerializer.Serialize(graph),
|
||||
Ndjson = ndjson
|
||||
}, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<EntryTraceResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(scanId, payload!.ScanId);
|
||||
Assert.Equal("sha256:entrytrace", payload.ImageDigest);
|
||||
Assert.Equal(graph.Outcome, payload.Graph.Outcome);
|
||||
Assert.Single(payload.Graph.Plans);
|
||||
Assert.Equal("/bin/bash", payload.Graph.Plans[0].TerminalPath);
|
||||
Assert.Single(payload.Graph.Terminals);
|
||||
Assert.Equal(ndjson, payload.Ndjson);
|
||||
}
|
||||
|
||||
private sealed class RecordingCoordinator : IScanCoordinator
|
||||
{
|
||||
private readonly IHttpContextAccessor accessor;
|
||||
private readonly InMemoryScanCoordinator inner;
|
||||
|
||||
public RecordingCoordinator(IHttpContextAccessor accessor, TimeProvider timeProvider, IScanProgressPublisher publisher)
|
||||
{
|
||||
@@ -358,15 +424,111 @@ public sealed class ScansEndpointsTests
|
||||
Assert.Equal(new[] { "alpha", "Beta", "zeta" }, names);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryTraceReturnsStoredResult()
|
||||
{
|
||||
var scanId = $"scan-{Guid.NewGuid():n}";
|
||||
var generatedAt = new DateTimeOffset(2025, 11, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
var plan = new EntryTracePlan(
|
||||
ImmutableArray.Create("/usr/local/bin/app"),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"/workspace",
|
||||
"appuser",
|
||||
"/usr/local/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"go",
|
||||
90d,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
var terminal = new EntryTraceTerminal(
|
||||
"/usr/local/bin/app",
|
||||
EntryTraceTerminalType.Native,
|
||||
"go",
|
||||
90d,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
"appuser",
|
||||
"/workspace",
|
||||
ImmutableArray<string>.Empty);
|
||||
var graph = new EntryTraceGraph(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray.Create(plan),
|
||||
ImmutableArray.Create(terminal));
|
||||
var ndjson = EntryTraceNdjsonWriter.Serialize(
|
||||
graph,
|
||||
new EntryTraceNdjsonMetadata(scanId, "sha256:test", generatedAt));
|
||||
var storedResult = new EntryTraceResult(scanId, "sha256:test", generatedAt, graph, ndjson);
|
||||
|
||||
using var factory = new ScannerApplicationFactory(
|
||||
configuration: null,
|
||||
services =>
|
||||
{
|
||||
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(storedResult));
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/entrytrace");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<EntryTraceResponse>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(storedResult.ScanId, payload!.ScanId);
|
||||
Assert.Equal(storedResult.ImageDigest, payload.ImageDigest);
|
||||
Assert.Equal(storedResult.GeneratedAtUtc, payload.GeneratedAt);
|
||||
Assert.Equal(storedResult.Graph.Plans.Length, payload.Graph.Plans.Length);
|
||||
Assert.Equal(storedResult.Ndjson, payload.Ndjson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntryTraceReturnsNotFoundWhenMissing()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory(
|
||||
configuration: null,
|
||||
services =>
|
||||
{
|
||||
services.AddSingleton<IEntryTraceResultStore>(new StubEntryTraceResultStore(null));
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/scans/scan-missing/entrytrace");
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private sealed record ProgressEnvelope(
|
||||
string ScanId,
|
||||
int Sequence,
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private sealed record ProgressEnvelope(
|
||||
string ScanId,
|
||||
int Sequence,
|
||||
string State,
|
||||
string? Message,
|
||||
DateTimeOffset Timestamp,
|
||||
string CorrelationId,
|
||||
Dictionary<string, JsonElement> Data);
|
||||
}
|
||||
string CorrelationId,
|
||||
Dictionary<string, JsonElement> Data);
|
||||
|
||||
private sealed class StubEntryTraceResultStore : IEntryTraceResultStore
|
||||
{
|
||||
private readonly EntryTraceResult? _result;
|
||||
|
||||
public StubEntryTraceResultStore(EntryTraceResult? result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_result is not null && string.Equals(_result.ScanId, scanId, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult<EntryTraceResult?>(_result);
|
||||
}
|
||||
|
||||
return Task.FromResult<EntryTraceResult?>(null);
|
||||
}
|
||||
|
||||
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,179 +1,492 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class EntryTraceExecutionServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public EntryTraceExecutionServiceTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Skips_When_ConfigMetadataMissing()
|
||||
{
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var service = CreateService(analyzer);
|
||||
|
||||
var context = CreateContext(new Dictionary<string, string>());
|
||||
|
||||
await service.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.False(analyzer.Invoked);
|
||||
Assert.False(context.Analysis.TryGet<EntryTraceGraph>(ScanAnalysisKeys.EntryTraceGraph, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_BuildsContext_AndStoresGraph()
|
||||
{
|
||||
var configPath = Path.Combine(_tempRoot, "config.json");
|
||||
File.WriteAllText(configPath, """
|
||||
{
|
||||
"config": {
|
||||
"Env": ["PATH=/bin:/usr/bin"],
|
||||
"Entrypoint": ["/entrypoint.sh"],
|
||||
"WorkingDir": "/workspace",
|
||||
"User": "scanner"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var layerDirectory = Path.Combine(_tempRoot, "layer-1");
|
||||
Directory.CreateDirectory(layerDirectory);
|
||||
File.WriteAllText(Path.Combine(layerDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[ScanMetadataKeys.ImageConfigPath] = configPath,
|
||||
[ScanMetadataKeys.LayerDirectories] = layerDirectory,
|
||||
["image.digest"] = "sha256:test-digest"
|
||||
};
|
||||
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var service = CreateService(analyzer);
|
||||
|
||||
var context = CreateContext(metadata);
|
||||
|
||||
await service.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.True(analyzer.Invoked);
|
||||
Assert.NotNull(analyzer.LastEntrypoint);
|
||||
Assert.Equal("/entrypoint.sh", analyzer.LastEntrypoint!.Entrypoint[0]);
|
||||
Assert.NotNull(analyzer.LastContext);
|
||||
Assert.Equal("scanner", analyzer.LastContext!.User);
|
||||
Assert.Equal("/workspace", analyzer.LastContext.WorkingDirectory);
|
||||
Assert.Contains("/bin", analyzer.LastContext.Path);
|
||||
|
||||
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out EntryTraceGraph stored));
|
||||
Assert.Same(analyzer.Graph, stored);
|
||||
}
|
||||
|
||||
private EntryTraceExecutionService CreateService(IEntryTraceAnalyzer analyzer)
|
||||
{
|
||||
var workerOptions = new ScannerWorkerOptions();
|
||||
var entryTraceOptions = new EntryTraceAnalyzerOptions();
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace));
|
||||
return new EntryTraceExecutionService(
|
||||
analyzer,
|
||||
Options.Create(entryTraceOptions),
|
||||
Options.Create(workerOptions),
|
||||
loggerFactory.CreateLogger<EntryTraceExecutionService>(),
|
||||
loggerFactory);
|
||||
}
|
||||
|
||||
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
var lease = new TestLease(metadata);
|
||||
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingEntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
{
|
||||
public bool Invoked { get; private set; }
|
||||
|
||||
public EntrypointSpecification? LastEntrypoint { get; private set; }
|
||||
|
||||
public EntryTraceContext? LastContext { get; private set; }
|
||||
|
||||
public EntryTraceGraph Graph { get; } = new(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty);
|
||||
|
||||
public ValueTask<EntryTraceGraph> ResolveAsync(EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Invoked = true;
|
||||
LastEntrypoint = entrypoint;
|
||||
LastContext = context;
|
||||
return ValueTask.FromResult(Graph);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestLease : IScanJobLease
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> _metadata;
|
||||
|
||||
public TestLease(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
_metadata = metadata;
|
||||
EnqueuedAtUtc = DateTimeOffset.UtcNow;
|
||||
LeasedAtUtc = EnqueuedAtUtc;
|
||||
}
|
||||
|
||||
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
|
||||
|
||||
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
|
||||
|
||||
public int Attempt => 1;
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata => _metadata;
|
||||
|
||||
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.EntryTrace.Runtime;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
using StellaOps.Scanner.Surface.Secrets;
|
||||
using StellaOps.Scanner.Surface.Validation;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests;
|
||||
|
||||
public sealed class EntryTraceExecutionServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempRoot;
|
||||
|
||||
public EntryTraceExecutionServiceTests()
|
||||
{
|
||||
_tempRoot = Path.Combine(Path.GetTempPath(), $"entrytrace-service-{Guid.NewGuid():n}");
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Skips_When_ConfigMetadataMissing()
|
||||
{
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var store = new CapturingEntryTraceResultStore();
|
||||
var service = CreateService(analyzer, store);
|
||||
|
||||
var context = CreateContext(new Dictionary<string, string>());
|
||||
|
||||
await service.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.False(analyzer.Invoked);
|
||||
Assert.False(context.Analysis.TryGet<EntryTraceGraph>(ScanAnalysisKeys.EntryTraceGraph, out _));
|
||||
Assert.False(store.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_BuildsContext_AndStoresGraph()
|
||||
{
|
||||
var metadata = CreateMetadata("PATH=/bin:/usr/bin");
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var store = new CapturingEntryTraceResultStore();
|
||||
var service = CreateService(analyzer, store);
|
||||
|
||||
var context = CreateContext(metadata);
|
||||
|
||||
await service.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.True(analyzer.Invoked);
|
||||
Assert.NotNull(analyzer.LastEntrypoint);
|
||||
Assert.Equal("/entrypoint.sh", analyzer.LastEntrypoint!.Entrypoint[0]);
|
||||
Assert.NotNull(analyzer.LastContext);
|
||||
Assert.Equal("scanner", analyzer.LastContext!.User);
|
||||
Assert.Equal("/workspace", analyzer.LastContext.WorkingDirectory);
|
||||
Assert.Contains("/bin", analyzer.LastContext.Path);
|
||||
|
||||
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceGraph, out EntryTraceGraph stored));
|
||||
Assert.Equal(analyzer.Graph.Outcome, stored.Outcome);
|
||||
Assert.Contains(stored.Diagnostics, diagnostic => diagnostic.Reason == EntryTraceUnknownReason.RuntimeSnapshotUnavailable);
|
||||
Assert.True(context.Analysis.TryGet(ScanAnalysisKeys.EntryTraceNdjson, out ImmutableArray<string> ndjsonPayload));
|
||||
Assert.False(ndjsonPayload.IsDefaultOrEmpty);
|
||||
Assert.True(store.Stored);
|
||||
Assert.NotNull(store.LastResult);
|
||||
Assert.Equal(context.ScanId, store.LastResult!.ScanId);
|
||||
Assert.Equal("sha256:test-digest", store.LastResult.ImageDigest);
|
||||
Assert.Equal(stored, store.LastResult.Graph);
|
||||
Assert.Equal(ndjsonPayload, store.LastResult.Ndjson);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_UsesCachedGraphWhenAvailable()
|
||||
{
|
||||
var metadata = CreateMetadata("PATH=/bin:/usr/bin");
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var store = new CapturingEntryTraceResultStore();
|
||||
var cache = new InMemorySurfaceCache();
|
||||
var service = CreateService(analyzer, store, surfaceCache: cache);
|
||||
|
||||
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
|
||||
Assert.True(analyzer.Invoked);
|
||||
|
||||
analyzer.Reset();
|
||||
store.Reset();
|
||||
|
||||
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
|
||||
|
||||
Assert.False(analyzer.Invoked);
|
||||
Assert.True(store.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReplacesSecretReferencesUsingSurfaceSecrets()
|
||||
{
|
||||
var metadata = CreateMetadata("API_KEY=secret://inline/api-key");
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var store = new CapturingEntryTraceResultStore();
|
||||
var secrets = new StubSurfaceSecretProvider(new Dictionary<(string Type, string Name), byte[]>
|
||||
{
|
||||
{("inline", "api-key"), Encoding.UTF8.GetBytes("resolved-value")}
|
||||
});
|
||||
var service = CreateService(analyzer, store, surfaceSecrets: secrets);
|
||||
|
||||
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
|
||||
|
||||
Assert.True(analyzer.Invoked);
|
||||
Assert.Equal("resolved-value", analyzer.LastContext!.Environment["API_KEY"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_FallsBackToBase64ForBinarySecrets()
|
||||
{
|
||||
var metadata = CreateMetadata("BLOB=secret://inline/blob");
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var store = new CapturingEntryTraceResultStore();
|
||||
var payload = new byte[] { 0x00, 0xFF, 0x10 };
|
||||
var secrets = new StubSurfaceSecretProvider(new Dictionary<(string Type, string Name), byte[]>
|
||||
{
|
||||
{("inline", "blob"), payload}
|
||||
});
|
||||
var service = CreateService(analyzer, store, surfaceSecrets: secrets);
|
||||
|
||||
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
|
||||
|
||||
Assert.True(analyzer.Invoked);
|
||||
Assert.Equal(Convert.ToBase64String(payload), analyzer.LastContext!.Environment["BLOB"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SkipsWhenSurfaceValidationFails()
|
||||
{
|
||||
var metadata = CreateMetadata("PATH=/bin:/usr/bin");
|
||||
var analyzer = new CapturingEntryTraceAnalyzer();
|
||||
var store = new CapturingEntryTraceResultStore();
|
||||
var issues = new[]
|
||||
{
|
||||
SurfaceValidationIssue.Error("cache", "unwritable")
|
||||
};
|
||||
var validator = new StaticSurfaceValidatorRunner(SurfaceValidationResult.FromIssues(issues));
|
||||
var service = CreateService(analyzer, store, surfaceValidator: validator);
|
||||
|
||||
await service.ExecuteAsync(CreateContext(metadata), CancellationToken.None);
|
||||
|
||||
Assert.False(analyzer.Invoked);
|
||||
Assert.False(store.Stored);
|
||||
}
|
||||
|
||||
private EntryTraceExecutionService CreateService(
|
||||
IEntryTraceAnalyzer analyzer,
|
||||
IEntryTraceResultStore store,
|
||||
ISurfaceCache? surfaceCache = null,
|
||||
ISurfaceValidatorRunner? surfaceValidator = null,
|
||||
ISurfaceSecretProvider? surfaceSecrets = null,
|
||||
ISurfaceEnvironment? surfaceEnvironment = null)
|
||||
{
|
||||
var workerOptions = new ScannerWorkerOptions();
|
||||
var entryTraceOptions = new EntryTraceAnalyzerOptions();
|
||||
|
||||
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Trace));
|
||||
surfaceEnvironment ??= new StubSurfaceEnvironment();
|
||||
surfaceCache ??= new InMemorySurfaceCache();
|
||||
surfaceValidator ??= new NoopSurfaceValidatorRunner();
|
||||
surfaceSecrets ??= new StubSurfaceSecretProvider();
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<ISurfaceEnvironment>(surfaceEnvironment)
|
||||
.BuildServiceProvider();
|
||||
|
||||
return new EntryTraceExecutionService(
|
||||
analyzer,
|
||||
Microsoft.Extensions.Options.Options.Create(entryTraceOptions),
|
||||
Microsoft.Extensions.Options.Options.Create(workerOptions),
|
||||
loggerFactory.CreateLogger<EntryTraceExecutionService>(),
|
||||
loggerFactory,
|
||||
new EntryTraceRuntimeReconciler(),
|
||||
store,
|
||||
surfaceValidator,
|
||||
surfaceEnvironment,
|
||||
surfaceCache,
|
||||
surfaceSecrets,
|
||||
serviceProvider);
|
||||
}
|
||||
|
||||
private static ScanJobContext CreateContext(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
var lease = new TestLease(metadata);
|
||||
return new ScanJobContext(lease, TimeProvider.System, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
}
|
||||
|
||||
private Dictionary<string, string> CreateMetadata(params string[] environmentEntries)
|
||||
{
|
||||
var configPath = Path.Combine(_tempRoot, $"config-{Guid.NewGuid():n}.json");
|
||||
var env = environmentEntries.Length == 0
|
||||
? new[] { "PATH=/bin:/usr/bin" }
|
||||
: environmentEntries;
|
||||
var envJson = string.Join(",", env.Select(value => $"\"{value}\""));
|
||||
File.WriteAllText(configPath,
|
||||
$$"""
|
||||
{
|
||||
"config": {
|
||||
"Env": [{{envJson}}],
|
||||
"Entrypoint": ["/entrypoint.sh"],
|
||||
"WorkingDir": "/workspace",
|
||||
"User": "scanner"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var rootDirectory = Path.Combine(_tempRoot, $"root-{Guid.NewGuid():n}");
|
||||
Directory.CreateDirectory(rootDirectory);
|
||||
File.WriteAllText(Path.Combine(rootDirectory, "entrypoint.sh"), "#!/bin/sh\necho hello\n");
|
||||
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
[ScanMetadataKeys.ImageConfigPath] = configPath,
|
||||
[ScanMetadataKeys.RootFilesystemPath] = rootDirectory,
|
||||
[ScanMetadataKeys.LayerDirectories] = rootDirectory,
|
||||
["image.digest"] = "sha256:test-digest"
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingEntryTraceAnalyzer : IEntryTraceAnalyzer
|
||||
{
|
||||
public bool Invoked { get; private set; }
|
||||
|
||||
public EntrypointSpecification? LastEntrypoint { get; private set; }
|
||||
|
||||
public EntryTraceContext? LastContext { get; private set; }
|
||||
|
||||
public EntryTraceGraph Graph { get; } = new(
|
||||
EntryTraceOutcome.Resolved,
|
||||
ImmutableArray<EntryTraceNode>.Empty,
|
||||
ImmutableArray<EntryTraceEdge>.Empty,
|
||||
ImmutableArray<EntryTraceDiagnostic>.Empty,
|
||||
ImmutableArray<EntryTracePlan>.Empty,
|
||||
ImmutableArray<EntryTraceTerminal>.Empty);
|
||||
|
||||
public ValueTask<EntryTraceGraph> ResolveAsync(EntrypointSpecification entrypoint, EntryTraceContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Invoked = true;
|
||||
LastEntrypoint = entrypoint;
|
||||
LastContext = context;
|
||||
return ValueTask.FromResult(Graph);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Invoked = false;
|
||||
LastEntrypoint = null;
|
||||
LastContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingEntryTraceResultStore : IEntryTraceResultStore
|
||||
{
|
||||
public bool Stored { get; private set; }
|
||||
public EntryTraceResult? LastResult { get; private set; }
|
||||
|
||||
public Task<EntryTraceResult?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<EntryTraceResult?>(null);
|
||||
}
|
||||
|
||||
public Task StoreAsync(EntryTraceResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
Stored = true;
|
||||
LastResult = result;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Stored = false;
|
||||
LastResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestLease : IScanJobLease
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> _metadata;
|
||||
|
||||
public TestLease(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
_metadata = metadata;
|
||||
EnqueuedAtUtc = DateTimeOffset.UtcNow;
|
||||
LeasedAtUtc = EnqueuedAtUtc;
|
||||
}
|
||||
|
||||
public string JobId { get; } = $"job-{Guid.NewGuid():n}";
|
||||
|
||||
public string ScanId { get; } = $"scan-{Guid.NewGuid():n}";
|
||||
|
||||
public int Attempt => 1;
|
||||
|
||||
public DateTimeOffset EnqueuedAtUtc { get; }
|
||||
|
||||
public DateTimeOffset LeasedAtUtc { get; }
|
||||
|
||||
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
|
||||
|
||||
public IReadOnlyDictionary<string, string> Metadata => _metadata;
|
||||
|
||||
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class StubSurfaceEnvironment : ISurfaceEnvironment
|
||||
{
|
||||
public StubSurfaceEnvironment()
|
||||
{
|
||||
var cacheRoot = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "surface-cache-tests"));
|
||||
Settings = new SurfaceEnvironmentSettings(
|
||||
new Uri("https://surface.example"),
|
||||
"surface-cache",
|
||||
null,
|
||||
cacheRoot,
|
||||
1024,
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
new SurfaceSecretsConfiguration("inline", "tenant", null, null, null, AllowInline: true),
|
||||
"tenant",
|
||||
new SurfaceTlsConfiguration(null, null, null));
|
||||
RawVariables = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public SurfaceEnvironmentSettings Settings { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, string> RawVariables { get; }
|
||||
}
|
||||
|
||||
private sealed class NoopSurfaceValidatorRunner : ISurfaceValidatorRunner
|
||||
{
|
||||
public ValueTask<SurfaceValidationResult> RunAllAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(SurfaceValidationResult.Success());
|
||||
}
|
||||
|
||||
public ValueTask EnsureAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private sealed class StaticSurfaceValidatorRunner : ISurfaceValidatorRunner
|
||||
{
|
||||
private readonly SurfaceValidationResult _result;
|
||||
|
||||
public StaticSurfaceValidatorRunner(SurfaceValidationResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceValidationResult> RunAllAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
|
||||
public ValueTask EnsureAsync(SurfaceValidationContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemorySurfaceCache : ISurfaceCache
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _store = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
Func<T, ReadOnlyMemory<byte>> serializer,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (TryRead(key, deserializer, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = await factory(cancellationToken).ConfigureAwait(false);
|
||||
var payload = serializer(created).ToArray();
|
||||
lock (_gate)
|
||||
{
|
||||
_store[key.ToString()] = payload;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
public Task<T?> TryGetAsync<T>(
|
||||
SurfaceCacheKey key,
|
||||
Func<ReadOnlyMemory<byte>, T> deserializer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(TryRead(key, deserializer, out var value) ? value : default);
|
||||
}
|
||||
|
||||
public Task SetAsync(
|
||||
SurfaceCacheKey key,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_store[key.ToString()] = payload.ToArray();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool TryRead<T>(SurfaceCacheKey key, Func<ReadOnlyMemory<byte>, T> deserializer, out T value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_store.TryGetValue(key.ToString(), out var bytes))
|
||||
{
|
||||
value = deserializer(new ReadOnlyMemory<byte>(bytes));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubSurfaceSecretProvider : ISurfaceSecretProvider
|
||||
{
|
||||
private readonly Dictionary<(string Type, string Name), byte[]> _secrets;
|
||||
private readonly bool _throwOnMissing;
|
||||
|
||||
public StubSurfaceSecretProvider(Dictionary<(string Type, string Name), byte[]>? secrets = null, bool throwOnMissing = false)
|
||||
{
|
||||
_secrets = secrets ?? new Dictionary<(string Type, string Name), byte[]>();
|
||||
_throwOnMissing = throwOnMissing;
|
||||
}
|
||||
|
||||
public ValueTask<SurfaceSecretHandle> GetAsync(SurfaceSecretRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = (request.SecretType, request.Name ?? string.Empty);
|
||||
if (_secrets.TryGetValue(key, out var payload))
|
||||
{
|
||||
return ValueTask.FromResult(SurfaceSecretHandle.FromBytes(payload));
|
||||
}
|
||||
|
||||
if (_throwOnMissing)
|
||||
{
|
||||
throw new SurfaceSecretNotFoundException(request);
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(SurfaceSecretHandle.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ public sealed class RedisWorkerSmokeTests
|
||||
public async Task<IScanJobLease?> TryAcquireAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new QueueLeaseRequest(_consumerName, 1, _queueOptions.DefaultLeaseDuration);
|
||||
var leases = await _queue.LeaseAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var leases = await _queue.LeaseAsync(request, cancellationToken);
|
||||
if (leases.Count == 0)
|
||||
{
|
||||
return null;
|
||||
@@ -221,23 +221,23 @@ public sealed class RedisWorkerSmokeTests
|
||||
|
||||
public async ValueTask RenewAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.RenewAsync(_options.DefaultLeaseDuration, cancellationToken).ConfigureAwait(false);
|
||||
await _lease.RenewAsync(_options.DefaultLeaseDuration, cancellationToken);
|
||||
}
|
||||
|
||||
public async ValueTask CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.AcknowledgeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _lease.AcknowledgeAsync(cancellationToken);
|
||||
_deps.JobCompleted.TrySetResult();
|
||||
}
|
||||
|
||||
public async ValueTask AbandonAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.ReleaseAsync(QueueReleaseDisposition.Retry, cancellationToken).ConfigureAwait(false);
|
||||
await _lease.ReleaseAsync(QueueReleaseDisposition.Retry, cancellationToken);
|
||||
}
|
||||
|
||||
public async ValueTask PoisonAsync(string reason, CancellationToken cancellationToken)
|
||||
{
|
||||
await _lease.DeadLetterAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||
await _lease.DeadLetterAsync(reason, cancellationToken);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user