up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"modulePath": "example.com/app",
|
||||
"modulePath.main": "example.com/app",
|
||||
"moduleSum": "h1:mainchecksum",
|
||||
"moduleVersion": "v1.2.3"
|
||||
"moduleVersion": "v1.2.3",
|
||||
"provenance": "binary"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
@@ -103,7 +104,8 @@
|
||||
"binaryPath": "app",
|
||||
"modulePath": "example.com/lib",
|
||||
"moduleSum": "h1:depchecksum",
|
||||
"moduleVersion": "v1.0.0"
|
||||
"moduleVersion": "v1.0.0",
|
||||
"provenance": "binary"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"modulePath": "example.com/app",
|
||||
"modulePath.main": "example.com/app",
|
||||
"moduleSum": "h1:dwarfchecksum",
|
||||
"moduleVersion": "v0.0.0"
|
||||
"moduleVersion": "v0.0.0",
|
||||
"provenance": "binary"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
@@ -65,7 +66,8 @@
|
||||
"binaryPath": "app",
|
||||
"modulePath": "example.com/lib",
|
||||
"moduleSum": "h1:libchecksum",
|
||||
"moduleVersion": "v0.1.0"
|
||||
"moduleVersion": "v0.1.0",
|
||||
"provenance": "binary"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
|
||||
@@ -248,6 +248,7 @@ public sealed class GoVersionConflictDetectorTests
|
||||
ImmutableArray<string>.Empty,
|
||||
GoVersionConflictDetector.GoConflictAnalysis.Empty,
|
||||
GoCgoDetector.CgoAnalysisResult.Empty,
|
||||
ImmutableArray<GoCapabilityEvidence>.Empty,
|
||||
null);
|
||||
|
||||
var inventory2 = new GoSourceInventory.SourceInventoryResult(
|
||||
@@ -263,6 +264,7 @@ public sealed class GoVersionConflictDetectorTests
|
||||
ImmutableArray<string>.Empty,
|
||||
GoVersionConflictDetector.GoConflictAnalysis.Empty,
|
||||
GoCgoDetector.CgoAnalysisResult.Empty,
|
||||
ImmutableArray<GoCapabilityEvidence>.Empty,
|
||||
null);
|
||||
|
||||
var result = GoVersionConflictDetector.AnalyzeWorkspace([inventory1, inventory2]);
|
||||
|
||||
@@ -645,6 +645,211 @@ public sealed class JavaLanguageAnalyzerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Multi-Module Gradle Lock & Runtime Image Tests (Sprint 0403)
|
||||
|
||||
[Fact]
|
||||
public async Task MultiModuleGradleLockFilesEmitLockModulePathMetadataAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Create root lockfile
|
||||
var rootLockPath = Path.Combine(root, "gradle.lockfile");
|
||||
await File.WriteAllTextAsync(rootLockPath, """
|
||||
# Root lockfile
|
||||
com.example:root-dep:1.0.0=compileClasspath
|
||||
com.example:shared-dep:2.0.0=runtimeClasspath
|
||||
""", cancellationToken);
|
||||
|
||||
// Create submodule directory and lockfile
|
||||
var appModuleDir = Path.Combine(root, "app");
|
||||
Directory.CreateDirectory(appModuleDir);
|
||||
var appLockPath = Path.Combine(appModuleDir, "gradle.lockfile");
|
||||
await File.WriteAllTextAsync(appLockPath, """
|
||||
# App module lockfile
|
||||
com.example:app-dep:3.0.0=compileClasspath
|
||||
com.example:shared-dep:2.5.0=runtimeClasspath
|
||||
""", cancellationToken);
|
||||
|
||||
// Create lib submodule directory and lockfile
|
||||
var libModuleDir = Path.Combine(root, "lib");
|
||||
Directory.CreateDirectory(libModuleDir);
|
||||
var libLockPath = Path.Combine(libModuleDir, "gradle.lockfile");
|
||||
await File.WriteAllTextAsync(libLockPath, """
|
||||
# Lib module lockfile
|
||||
com.example:lib-dep:4.0.0=testCompileClasspath
|
||||
""", cancellationToken);
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
root,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var components = document.RootElement.EnumerateArray().ToArray();
|
||||
|
||||
// Verify root-dep has lockModulePath="."
|
||||
var rootDep = components.FirstOrDefault(c => c.GetProperty("name").GetString() == "root-dep");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, rootDep.ValueKind);
|
||||
Assert.Equal(".", rootDep.GetProperty("metadata").GetProperty("lockModulePath").GetString());
|
||||
Assert.Equal("gradle.lockfile", rootDep.GetProperty("metadata").GetProperty("lockLocator").GetString());
|
||||
|
||||
// Verify app-dep has lockModulePath="app"
|
||||
var appDep = components.FirstOrDefault(c => c.GetProperty("name").GetString() == "app-dep");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, appDep.ValueKind);
|
||||
Assert.Equal("app", appDep.GetProperty("metadata").GetProperty("lockModulePath").GetString());
|
||||
Assert.Equal("app/gradle.lockfile", appDep.GetProperty("metadata").GetProperty("lockLocator").GetString());
|
||||
|
||||
// Verify lib-dep has lockModulePath="lib"
|
||||
var libDep = components.FirstOrDefault(c => c.GetProperty("name").GetString() == "lib-dep");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, libDep.ValueKind);
|
||||
Assert.Equal("lib", libDep.GetProperty("metadata").GetProperty("lockModulePath").GetString());
|
||||
|
||||
// Verify shared-dep: different versions result in both being emitted with conflict detection
|
||||
// (first-wins only applies to identical GAV, different versions are separate entries)
|
||||
var sharedDeps = components.Where(c => c.GetProperty("name").GetString() == "shared-dep").ToArray();
|
||||
Assert.Equal(2, sharedDeps.Length);
|
||||
|
||||
// Verify version conflict is detected
|
||||
foreach (var sharedDep in sharedDeps)
|
||||
{
|
||||
var metadata = sharedDep.GetProperty("metadata");
|
||||
Assert.Equal("true", metadata.GetProperty("conflict.detected").GetString());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuntimeImageEmitsExplicitKeyComponentAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Create a runtime image with release file
|
||||
var runtimeRoot = Path.Combine(root, "jdk", "jdk-21.0.1");
|
||||
Directory.CreateDirectory(runtimeRoot);
|
||||
Directory.CreateDirectory(Path.Combine(runtimeRoot, "bin"));
|
||||
Directory.CreateDirectory(Path.Combine(runtimeRoot, "lib"));
|
||||
|
||||
var javaBinary = OperatingSystem.IsWindows() ? "java.exe" : "java";
|
||||
await File.WriteAllTextAsync(Path.Combine(runtimeRoot, "bin", javaBinary), string.Empty, cancellationToken);
|
||||
await File.WriteAllTextAsync(Path.Combine(runtimeRoot, "lib", "modules"), string.Empty, cancellationToken);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(runtimeRoot, "release"),
|
||||
"JAVA_VERSION=\"21.0.1\"\nIMPLEMENTOR=\"Eclipse Adoptium\"\n",
|
||||
cancellationToken);
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
root,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var components = document.RootElement.EnumerateArray().ToArray();
|
||||
|
||||
// Find the runtime component
|
||||
var runtimeComponent = components.FirstOrDefault(c =>
|
||||
c.TryGetProperty("type", out var typeElement) &&
|
||||
typeElement.GetString() == "java-runtime");
|
||||
|
||||
Assert.NotEqual(JsonValueKind.Undefined, runtimeComponent.ValueKind);
|
||||
|
||||
// Verify no PURL (explicit-key)
|
||||
if (runtimeComponent.TryGetProperty("purl", out var purlElement))
|
||||
{
|
||||
Assert.Equal(JsonValueKind.Null, purlElement.ValueKind);
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
var metadata = runtimeComponent.GetProperty("metadata");
|
||||
Assert.Equal("21.0.1", metadata.GetProperty("java.version").GetString());
|
||||
Assert.Equal("Eclipse Adoptium", metadata.GetProperty("java.vendor").GetString());
|
||||
Assert.Equal("java-runtime", metadata.GetProperty("componentType").GetString());
|
||||
Assert.Equal("jdk/jdk-21.0.1", metadata.GetProperty("runtimeImagePath").GetString());
|
||||
|
||||
// Verify name format
|
||||
Assert.Equal("java-runtime-21.0.1 (Eclipse Adoptium)", runtimeComponent.GetProperty("name").GetString());
|
||||
Assert.Equal("21.0.1", runtimeComponent.GetProperty("version").GetString());
|
||||
|
||||
// Verify evidence references release file
|
||||
var evidence = runtimeComponent.GetProperty("evidence").EnumerateArray().ToArray();
|
||||
var releaseEvidence = evidence.FirstOrDefault(e =>
|
||||
e.GetProperty("source").GetString() == "release");
|
||||
Assert.NotEqual(JsonValueKind.Undefined, releaseEvidence.ValueKind);
|
||||
Assert.Equal("jdk/jdk-21.0.1/release", releaseEvidence.GetProperty("locator").GetString());
|
||||
Assert.True(releaseEvidence.TryGetProperty("sha256", out var sha256) && !string.IsNullOrWhiteSpace(sha256.GetString()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DuplicateRuntimeImagesAreDeduplicatedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var root = TestPaths.CreateTemporaryDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
// Create two identical runtime images in different locations
|
||||
foreach (var subPath in new[] { "runtime1/jdk", "runtime2/jdk" })
|
||||
{
|
||||
var runtimeRoot = Path.Combine(root, subPath);
|
||||
Directory.CreateDirectory(runtimeRoot);
|
||||
Directory.CreateDirectory(Path.Combine(runtimeRoot, "bin"));
|
||||
Directory.CreateDirectory(Path.Combine(runtimeRoot, "lib"));
|
||||
|
||||
var javaBinary = OperatingSystem.IsWindows() ? "java.exe" : "java";
|
||||
await File.WriteAllTextAsync(Path.Combine(runtimeRoot, "bin", javaBinary), string.Empty, cancellationToken);
|
||||
await File.WriteAllTextAsync(Path.Combine(runtimeRoot, "lib", "modules"), string.Empty, cancellationToken);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(runtimeRoot, "release"),
|
||||
"JAVA_VERSION=\"17.0.8\"\nIMPLEMENTOR=\"Azul Systems\"\n",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[] { new JavaLanguageAnalyzer() };
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
root,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var components = document.RootElement.EnumerateArray().ToArray();
|
||||
|
||||
// Both should be emitted because they have different paths
|
||||
var runtimeComponents = components.Where(c =>
|
||||
c.TryGetProperty("type", out var typeElement) &&
|
||||
typeElement.GetString() == "java-runtime").ToArray();
|
||||
|
||||
Assert.Equal(2, runtimeComponents.Length);
|
||||
|
||||
// Verify they have different runtimeImagePath
|
||||
var paths = runtimeComponents.Select(c =>
|
||||
c.GetProperty("metadata").GetProperty("runtimeImagePath").GetString()).ToHashSet();
|
||||
Assert.Contains("runtime1/jdk", paths);
|
||||
Assert.Contains("runtime2/jdk", paths);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TestPaths.SafeDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static bool ComponentHasMetadata(JsonElement root, string componentName, string key, string expected)
|
||||
{
|
||||
foreach (var element in root.EnumerateArray())
|
||||
|
||||
@@ -579,4 +579,213 @@ public sealed class PythonLanguageAnalyzerTests
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ===== SCAN-PY-405-007 Fixtures =====
|
||||
|
||||
[Fact]
|
||||
public async Task RequirementsWithIncludesAreFollowedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
// Create main requirements.txt that includes another file
|
||||
var requirementsPath = Path.Combine(fixturePath, "requirements.txt");
|
||||
await File.WriteAllTextAsync(requirementsPath, $"requests==2.28.0{Environment.NewLine}-r requirements-base.txt{Environment.NewLine}", cancellationToken);
|
||||
|
||||
// Create included requirements file
|
||||
var baseRequirementsPath = Path.Combine(fixturePath, "requirements-base.txt");
|
||||
await File.WriteAllTextAsync(baseRequirementsPath, $"urllib3==1.26.0{Environment.NewLine}certifi==2022.12.7{Environment.NewLine}", cancellationToken);
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new PythonLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
// All three packages should be found (from both files)
|
||||
Assert.True(ComponentHasMetadata(root, "requests", "declaredOnly", "true"));
|
||||
Assert.True(ComponentHasMetadata(root, "urllib3", "declaredOnly", "true"));
|
||||
Assert.True(ComponentHasMetadata(root, "certifi", "declaredOnly", "true"));
|
||||
|
||||
// urllib3 and certifi should come from the included file
|
||||
Assert.True(ComponentHasMetadata(root, "urllib3", "lockSource", "requirements-base.txt"));
|
||||
Assert.True(ComponentHasMetadata(root, "certifi", "lockSource", "requirements-base.txt"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(fixturePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PipfileLockDevelopSectionIsParsedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
// Create Pipfile.lock with default and develop sections
|
||||
var pipfileLockPath = Path.Combine(fixturePath, "Pipfile.lock");
|
||||
var pipfileLock = """
|
||||
{
|
||||
"_meta": { "sources": [] },
|
||||
"default": {
|
||||
"requests": { "version": "==2.28.0" }
|
||||
},
|
||||
"develop": {
|
||||
"pytest": { "version": "==7.0.0" }
|
||||
}
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(pipfileLockPath, pipfileLock, cancellationToken);
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new PythonLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
// Both packages should be found
|
||||
Assert.True(ComponentHasMetadata(root, "requests", "declaredOnly", "true"));
|
||||
Assert.True(ComponentHasMetadata(root, "pytest", "declaredOnly", "true"));
|
||||
|
||||
// requests should be prod scope, pytest should be dev scope
|
||||
Assert.True(ComponentHasMetadata(root, "requests", "scope", "prod"));
|
||||
Assert.True(ComponentHasMetadata(root, "pytest", "scope", "dev"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(fixturePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequirementsDevTxtGetsScopeDevAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
// Create requirements.txt for prod
|
||||
var requirementsPath = Path.Combine(fixturePath, "requirements.txt");
|
||||
await File.WriteAllTextAsync(requirementsPath, $"flask==2.0.0{Environment.NewLine}", cancellationToken);
|
||||
|
||||
// Create requirements-dev.txt for dev dependencies
|
||||
var requirementsDevPath = Path.Combine(fixturePath, "requirements-dev.txt");
|
||||
await File.WriteAllTextAsync(requirementsDevPath, $"pytest==7.0.0{Environment.NewLine}", cancellationToken);
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new PythonLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
// flask should be prod scope (from requirements.txt)
|
||||
Assert.True(ComponentHasMetadata(root, "flask", "scope", "prod"));
|
||||
|
||||
// pytest should be dev scope (from requirements-dev.txt)
|
||||
Assert.True(ComponentHasMetadata(root, "pytest", "scope", "dev"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(fixturePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pep508DirectReferenceIsParsedAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
// Create requirements.txt with direct reference
|
||||
var requirementsPath = Path.Combine(fixturePath, "requirements.txt");
|
||||
await File.WriteAllTextAsync(requirementsPath,
|
||||
$"mypackage @ https://example.com/packages/mypackage-1.0.0.whl{Environment.NewLine}",
|
||||
cancellationToken);
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new PythonLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
// Package should be found with URL reference
|
||||
Assert.True(ComponentHasMetadata(root, "mypackage", "declaredOnly", "true"));
|
||||
Assert.True(ComponentHasMetadata(root, "mypackage", "lockDirectUrl", "https://example.com/packages/mypackage-1.0.0.whl"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(fixturePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequirementsCycleIsDetectedAndHandledAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = CreateTemporaryWorkspace();
|
||||
try
|
||||
{
|
||||
// Create requirements.txt that includes base
|
||||
var requirementsPath = Path.Combine(fixturePath, "requirements.txt");
|
||||
await File.WriteAllTextAsync(requirementsPath, $"requests==2.28.0{Environment.NewLine}-r requirements-base.txt{Environment.NewLine}", cancellationToken);
|
||||
|
||||
// Create requirements-base.txt that includes back to main (cycle)
|
||||
var baseRequirementsPath = Path.Combine(fixturePath, "requirements-base.txt");
|
||||
await File.WriteAllTextAsync(baseRequirementsPath, $"urllib3==1.26.0{Environment.NewLine}-r requirements.txt{Environment.NewLine}", cancellationToken);
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new PythonLanguageAnalyzer()
|
||||
};
|
||||
|
||||
// Should not throw due to infinite loop
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
|
||||
// Both packages should still be found (cycle handled gracefully)
|
||||
Assert.True(ComponentHasMetadata(root, "requests", "declaredOnly", "true"));
|
||||
Assert.True(ComponentHasMetadata(root, "urllib3", "declaredOnly", "true"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(fixturePath, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,111 @@ public sealed class DotNetLanguageAnalyzerTests
|
||||
Assert.Contains("win-arm64", ridValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SourceTreeOnlyEmitsDeclaredPackagesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "source-tree-only");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new DotNetLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.ValueKind == JsonValueKind.Array, "Result root should be an array.");
|
||||
Assert.Equal(2, root.GetArrayLength());
|
||||
|
||||
// Check that packages are declared-only
|
||||
foreach (var component in root.EnumerateArray())
|
||||
{
|
||||
var metadata = component.GetProperty("metadata");
|
||||
Assert.Equal("true", metadata.GetProperty("declaredOnly").GetString());
|
||||
Assert.Equal("declared", metadata.GetProperty("provenance").GetString());
|
||||
}
|
||||
|
||||
// Check specific packages
|
||||
var newtonsoftJson = root.EnumerateArray()
|
||||
.First(element => element.GetProperty("name").GetString() == "Newtonsoft.Json");
|
||||
Assert.Equal("13.0.3", newtonsoftJson.GetProperty("version").GetString());
|
||||
Assert.Equal("pkg:nuget/newtonsoft.json@13.0.3", newtonsoftJson.GetProperty("purl").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockfileOnlyEmitsDeclaredPackagesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "lockfile-only");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new DotNetLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.ValueKind == JsonValueKind.Array, "Result root should be an array.");
|
||||
Assert.Equal(2, root.GetArrayLength());
|
||||
|
||||
// Check that packages are declared-only
|
||||
foreach (var component in root.EnumerateArray())
|
||||
{
|
||||
var metadata = component.GetProperty("metadata");
|
||||
Assert.Equal("true", metadata.GetProperty("declaredOnly").GetString());
|
||||
}
|
||||
|
||||
// Check direct vs transitive sources
|
||||
var directPackage = root.EnumerateArray()
|
||||
.First(element => element.GetProperty("name").GetString() == "Microsoft.Extensions.Logging");
|
||||
var transitivePackage = root.EnumerateArray()
|
||||
.First(element => element.GetProperty("name").GetString() == "Microsoft.Extensions.Logging.Abstractions");
|
||||
|
||||
Assert.Contains("Direct", directPackage.GetProperty("metadata").GetProperty("declared.source[0]").GetString());
|
||||
Assert.Contains("Transitive", transitivePackage.GetProperty("metadata").GetProperty("declared.source[0]").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackagesConfigOnlyEmitsDeclaredPackagesAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var fixturePath = TestPaths.ResolveFixture("lang", "dotnet", "packages-config-only");
|
||||
|
||||
var analyzers = new ILanguageAnalyzer[]
|
||||
{
|
||||
new DotNetLanguageAnalyzer()
|
||||
};
|
||||
|
||||
var json = await LanguageAnalyzerTestHarness.RunToJsonAsync(
|
||||
fixturePath,
|
||||
analyzers,
|
||||
cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
Assert.True(root.ValueKind == JsonValueKind.Array, "Result root should be an array.");
|
||||
Assert.Equal(2, root.GetArrayLength());
|
||||
|
||||
// Check that packages are from packages.config
|
||||
foreach (var component in root.EnumerateArray())
|
||||
{
|
||||
var metadata = component.GetProperty("metadata");
|
||||
Assert.Equal("true", metadata.GetProperty("declaredOnly").GetString());
|
||||
Assert.Equal("packages.config", metadata.GetProperty("declared.source[0]").GetString());
|
||||
Assert.Equal("net48", metadata.GetProperty("declared.tfm[0]").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubAuthenticodeInspector : IDotNetAuthenticodeInspector
|
||||
{
|
||||
public DotNetAuthenticodeMetadata? TryInspect(string assemblyPath, CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
[
|
||||
{
|
||||
"componentKey": "purl::pkg:nuget/microsoft.extensions.logging@8.0.0",
|
||||
"analyzerId": "dotnet",
|
||||
"purl": "pkg:nuget/microsoft.extensions.logging@8.0.0",
|
||||
"name": "Microsoft.Extensions.Logging",
|
||||
"version": "8.0.0",
|
||||
"type": "nuget",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"declared.locator[0]": "packages.lock.json",
|
||||
"declared.source[0]": "packages.lock.json (Direct)",
|
||||
"declared.tfm[0]": "net8.0",
|
||||
"declared.versionSource": "lockfile",
|
||||
"package.id": "Microsoft.Extensions.Logging",
|
||||
"package.id.normalized": "microsoft.extensions.logging",
|
||||
"package.version": "8.0.0",
|
||||
"provenance": "declared"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "File",
|
||||
"source": "packages.lock.json (Direct)",
|
||||
"locator": "packages.lock.json",
|
||||
"value": "Microsoft.Extensions.Logging@8.0.0",
|
||||
"sha256": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"componentKey": "purl::pkg:nuget/microsoft.extensions.logging.abstractions@8.0.0",
|
||||
"analyzerId": "dotnet",
|
||||
"purl": "pkg:nuget/microsoft.extensions.logging.abstractions@8.0.0",
|
||||
"name": "Microsoft.Extensions.Logging.Abstractions",
|
||||
"version": "8.0.0",
|
||||
"type": "nuget",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"declared.locator[0]": "packages.lock.json",
|
||||
"declared.source[0]": "packages.lock.json (Transitive)",
|
||||
"declared.tfm[0]": "net8.0",
|
||||
"declared.versionSource": "lockfile",
|
||||
"package.id": "Microsoft.Extensions.Logging.Abstractions",
|
||||
"package.id.normalized": "microsoft.extensions.logging.abstractions",
|
||||
"package.version": "8.0.0",
|
||||
"provenance": "declared"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "File",
|
||||
"source": "packages.lock.json (Transitive)",
|
||||
"locator": "packages.lock.json",
|
||||
"value": "Microsoft.Extensions.Logging.Abstractions@8.0.0",
|
||||
"sha256": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net8.0": {
|
||||
"Microsoft.Extensions.Logging": {
|
||||
"type": "Direct",
|
||||
"requested": "[8.0.0, )",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "ABC123"
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.0",
|
||||
"contentHash": "DEF456",
|
||||
"dependencies": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
"assembly[3].assetPath": "runtimes/osx-arm64/lib/net10.0/StellaOps.Logging.dll",
|
||||
"assembly[3].rid[0]": "osx-arm64",
|
||||
"assembly[3].tfm[0]": ".NETCoreApp,Version=v10.0",
|
||||
"declared.missing": "true",
|
||||
"deps.path[0]": "AppA.deps.json",
|
||||
"deps.path[1]": "AppB.deps.json",
|
||||
"deps.rid[0]": "linux-arm64",
|
||||
@@ -69,6 +70,7 @@
|
||||
"assembly[0].fileVersion": "1.2.3.0",
|
||||
"assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0",
|
||||
"assembly[0].version": "1.2.3.0",
|
||||
"declared.missing": "true",
|
||||
"deps.dependency[0]": "stellaops.logging",
|
||||
"deps.path[0]": "AppA.deps.json",
|
||||
"deps.path[1]": "AppB.deps.json",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
[
|
||||
{
|
||||
"componentKey": "purl::pkg:nuget/log4net@2.0.15",
|
||||
"analyzerId": "dotnet",
|
||||
"purl": "pkg:nuget/log4net@2.0.15",
|
||||
"name": "log4net",
|
||||
"version": "2.0.15",
|
||||
"type": "nuget",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"declared.locator[0]": "packages.config",
|
||||
"declared.source[0]": "packages.config",
|
||||
"declared.tfm[0]": "net48",
|
||||
"declared.versionSource": "packagesconfig",
|
||||
"package.id": "log4net",
|
||||
"package.id.normalized": "log4net",
|
||||
"package.version": "2.0.15",
|
||||
"provenance": "declared"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "File",
|
||||
"source": "packages.config",
|
||||
"locator": "packages.config",
|
||||
"value": "log4net@2.0.15",
|
||||
"sha256": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"componentKey": "purl::pkg:nuget/newtonsoft.json@13.0.3",
|
||||
"analyzerId": "dotnet",
|
||||
"purl": "pkg:nuget/newtonsoft.json@13.0.3",
|
||||
"name": "Newtonsoft.Json",
|
||||
"version": "13.0.3",
|
||||
"type": "nuget",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"declared.locator[0]": "packages.config",
|
||||
"declared.source[0]": "packages.config",
|
||||
"declared.tfm[0]": "net48",
|
||||
"declared.versionSource": "packagesconfig",
|
||||
"package.id": "Newtonsoft.Json",
|
||||
"package.id.normalized": "newtonsoft.json",
|
||||
"package.version": "13.0.3",
|
||||
"provenance": "declared"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "File",
|
||||
"source": "packages.config",
|
||||
"locator": "packages.config",
|
||||
"value": "Newtonsoft.Json@13.0.3",
|
||||
"sha256": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
||||
<package id="log4net" version="2.0.15" targetFramework="net48" />
|
||||
</packages>
|
||||
@@ -8,6 +8,7 @@
|
||||
"type": "nuget",
|
||||
"usedByEntrypoint": true,
|
||||
"metadata": {
|
||||
"declared.missing": "true",
|
||||
"deps.path[0]": "MyApp.deps.json",
|
||||
"deps.rid[0]": "linux-x64",
|
||||
"deps.rid[1]": "win-x64",
|
||||
@@ -61,6 +62,7 @@
|
||||
"assembly[0].rid[1]": "win-x64",
|
||||
"assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0",
|
||||
"assembly[0].version": "1.2.3.0",
|
||||
"declared.missing": "true",
|
||||
"deps.path[0]": "MyApp.deps.json",
|
||||
"deps.rid[0]": "linux-x64",
|
||||
"deps.rid[1]": "win-x64",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"assembly[1].assetPath": "runtimes/linux-x64/lib/net9.0/Microsoft.Extensions.Logging.dll",
|
||||
"assembly[1].rid[0]": "linux-x64",
|
||||
"assembly[1].tfm[0]": ".NETCoreApp,Version=v10.0",
|
||||
"declared.missing": "true",
|
||||
"deps.path[0]": "Signed.App.deps.json",
|
||||
"deps.rid[0]": "linux-x64",
|
||||
"deps.tfm[0]": ".NETCoreApp,Version=v10.0",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"assembly[2].assetPath": "runtimes/win-x86/lib/net9.0/Microsoft.Extensions.Logging.dll",
|
||||
"assembly[2].rid[0]": "win-x86",
|
||||
"assembly[2].tfm[0]": ".NETCoreApp,Version=v10.0",
|
||||
"declared.missing": "true",
|
||||
"deps.path[0]": "Sample.App.deps.json",
|
||||
"deps.rid[0]": "linux-x64",
|
||||
"deps.rid[1]": "win-x86",
|
||||
@@ -54,6 +55,7 @@
|
||||
"assembly[0].fileVersion": "1.2.3.0",
|
||||
"assembly[0].tfm[0]": ".NETCoreApp,Version=v10.0",
|
||||
"assembly[0].version": "1.2.3.0",
|
||||
"declared.missing": "true",
|
||||
"deps.dependency[0]": "microsoft.extensions.logging",
|
||||
"deps.path[0]": "Sample.App.deps.json",
|
||||
"deps.rid[0]": "linux-x64",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="Serilog" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="Serilog" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,60 @@
|
||||
[
|
||||
{
|
||||
"componentKey": "purl::pkg:nuget/newtonsoft.json@13.0.3",
|
||||
"analyzerId": "dotnet",
|
||||
"purl": "pkg:nuget/newtonsoft.json@13.0.3",
|
||||
"name": "Newtonsoft.Json",
|
||||
"version": "13.0.3",
|
||||
"type": "nuget",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"declared.locator[0]": "Sample.App.csproj",
|
||||
"declared.source[0]": "csproj",
|
||||
"declared.tfm[0]": "net8.0",
|
||||
"declared.versionSource": "centralpkg",
|
||||
"package.id": "Newtonsoft.Json",
|
||||
"package.id.normalized": "newtonsoft.json",
|
||||
"package.version": "13.0.3",
|
||||
"provenance": "declared"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "File",
|
||||
"source": "csproj",
|
||||
"locator": "Sample.App.csproj",
|
||||
"value": "Newtonsoft.Json@13.0.3",
|
||||
"sha256": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"componentKey": "purl::pkg:nuget/serilog@3.1.1",
|
||||
"analyzerId": "dotnet",
|
||||
"purl": "pkg:nuget/serilog@3.1.1",
|
||||
"name": "Serilog",
|
||||
"version": "3.1.1",
|
||||
"type": "nuget",
|
||||
"usedByEntrypoint": false,
|
||||
"metadata": {
|
||||
"declaredOnly": "true",
|
||||
"declared.locator[0]": "Sample.App.csproj",
|
||||
"declared.source[0]": "csproj",
|
||||
"declared.tfm[0]": "net8.0",
|
||||
"declared.versionSource": "centralpkg",
|
||||
"package.id": "Serilog",
|
||||
"package.id.normalized": "serilog",
|
||||
"package.version": "3.1.1",
|
||||
"provenance": "declared"
|
||||
},
|
||||
"evidence": [
|
||||
{
|
||||
"kind": "File",
|
||||
"source": "csproj",
|
||||
"locator": "Sample.App.csproj",
|
||||
"value": "Serilog@3.1.1",
|
||||
"sha256": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,425 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic.Adapters;
|
||||
using Xunit;
|
||||
|
||||
// Alias to distinguish from root namespace EntrypointSpecification
|
||||
using SemanticSpec = StellaOps.Scanner.EntryTrace.Semantic.EntrypointSpecification;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixtures for semantic entrypoint adapters.
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 21).
|
||||
/// </summary>
|
||||
public sealed class SemanticAdapterTests
|
||||
{
|
||||
#region Python Adapter Tests
|
||||
|
||||
[Fact]
|
||||
public async Task PythonAdapter_Django_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/manage.py", "#!/usr/bin/env python\nimport django\ndjango.setup()");
|
||||
fs.AddFile("/app/requirements.txt", "django==4.2.0\npsycopg2==2.9.0");
|
||||
fs.AddFile("/app/myproject/settings.py", "DATABASES = {'default': ...}");
|
||||
|
||||
var context = CreateContext(fs, "/app/manage.py", "python");
|
||||
var adapter = new PythonSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("python", result.Language);
|
||||
Assert.Equal("django", result.Framework);
|
||||
Assert.True(result.Capabilities.HasFlag(CapabilityClass.NetworkListen));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PythonAdapter_Flask_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/app.py", "#!/usr/bin/env python\nfrom flask import Flask\napp = Flask(__name__)\napp.run()");
|
||||
fs.AddFile("/app/requirements.txt", "flask==3.0.0\nredis==5.0.0");
|
||||
|
||||
var context = CreateContext(fs, "/app/app.py", "python");
|
||||
var adapter = new PythonSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("flask", result.Framework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PythonAdapter_Celery_InfersWorkerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/tasks.py", "from celery import Celery\napp = Celery('tasks')");
|
||||
fs.AddFile("/app/requirements.txt", "celery==5.3.0");
|
||||
|
||||
var context = CreateContext(fs, "/app/tasks.py", "python");
|
||||
context = context with
|
||||
{
|
||||
Specification = new SemanticSpec
|
||||
{
|
||||
Entrypoint = ImmutableArray.Create("celery", "-A", "tasks", "worker")
|
||||
}
|
||||
};
|
||||
var adapter = new PythonSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.Worker, result.Intent);
|
||||
Assert.True(result.Capabilities.HasFlag(CapabilityClass.MessageQueue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PythonAdapter_Click_InfersCliToolIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/cli.py", "#!/usr/bin/env python\nimport click\n@click.command()\ndef main(): pass");
|
||||
fs.AddFile("/app/requirements.txt", "click==8.0.0");
|
||||
|
||||
var context = CreateContext(fs, "/app/cli.py", "python");
|
||||
var adapter = new PythonSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.CliTool, result.Intent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PythonAdapter_Lambda_InfersServerlessIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/handler.py", "def lambda_handler(event, context):\n return {'statusCode': 200}");
|
||||
fs.AddFile("/app/requirements.txt", "boto3==1.28.0");
|
||||
|
||||
var context = CreateContext(fs, "/app/handler.py", "python");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["serverless"] = "/app/serverless.yml" } };
|
||||
var adapter = new PythonSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.Serverless, result.Intent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Java Adapter Tests
|
||||
|
||||
[Fact]
|
||||
public async Task JavaAdapter_SpringBoot_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/pom.xml", "<project><dependencies><dependency><groupId>org.springframework.boot</groupId></dependency></dependencies></project>");
|
||||
fs.AddFile("/app/src/main/java/Application.java", "@SpringBootApplication\npublic class Application {}");
|
||||
fs.AddFile("/app/target/app.jar", "PK\x03\x04...");
|
||||
|
||||
var context = CreateContext(fs, "/app/target/app.jar", "java");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["pom.xml"] = "/app/pom.xml" } };
|
||||
var adapter = new JavaSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("java", result.Language);
|
||||
Assert.Contains("spring-boot", result.Framework ?? "");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JavaAdapter_Quarkus_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/pom.xml", "<project><dependencies><dependency><groupId>io.quarkus</groupId></dependency></dependencies></project>");
|
||||
|
||||
var context = CreateContext(fs, "/app/target/app.jar", "java");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["pom.xml"] = "/app/pom.xml" } };
|
||||
var adapter = new JavaSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("quarkus", result.Framework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task JavaAdapter_KafkaStreams_InfersStreamProcessorIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/pom.xml", "<project><dependencies><dependency><groupId>org.apache.kafka</groupId><artifactId>kafka-streams</artifactId></dependency></dependencies></project>");
|
||||
|
||||
var context = CreateContext(fs, "/app/target/app.jar", "java");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["pom.xml"] = "/app/pom.xml" } };
|
||||
var adapter = new JavaSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.StreamProcessor, result.Intent);
|
||||
Assert.True(result.Capabilities.HasFlag(CapabilityClass.MessageQueue));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Node Adapter Tests
|
||||
|
||||
[Fact]
|
||||
public async Task NodeAdapter_Express_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/package.json", """{"name":"app","dependencies":{"express":"^4.18.0"},"main":"index.js"}""");
|
||||
fs.AddFile("/app/index.js", "const express = require('express');\nconst app = express();\napp.listen(3000);");
|
||||
|
||||
var context = CreateContext(fs, "/app/index.js", "node");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["package.json"] = "/app/package.json" } };
|
||||
var adapter = new NodeSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("node", result.Language);
|
||||
Assert.Equal("express", result.Framework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeAdapter_NestJS_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/package.json", """{"name":"app","dependencies":{"@nestjs/core":"^10.0.0"}}""");
|
||||
|
||||
var context = CreateContext(fs, "/app/dist/main.js", "node");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["package.json"] = "/app/package.json" } };
|
||||
var adapter = new NodeSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("nestjs", result.Framework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeAdapter_CliBin_InfersCliToolIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/package.json", """{"name":"my-cli","bin":{"mycli":"./cli.js"}}""");
|
||||
fs.AddFile("/app/cli.js", "#!/usr/bin/env node\nconsole.log('hello');");
|
||||
|
||||
var context = CreateContext(fs, "/app/cli.js", "node");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["package.json"] = "/app/package.json" } };
|
||||
var adapter = new NodeSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.CliTool, result.Intent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeAdapter_AwsLambda_InfersServerlessIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/package.json", """{"name":"lambda","dependencies":{"aws-sdk":"^2.0.0"}}""");
|
||||
fs.AddFile("/app/handler.js", "exports.handler = async (event, context) => { return { statusCode: 200 }; }");
|
||||
fs.AddFile("/app/serverless.yml", "service: my-service\nprovider:\n name: aws");
|
||||
|
||||
var context = CreateContext(fs, "/app/handler.js", "node");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string>
|
||||
{
|
||||
["package.json"] = "/app/package.json",
|
||||
["serverless"] = "/app/serverless.yml"
|
||||
}};
|
||||
var adapter = new NodeSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.Serverless, result.Intent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region .NET Adapter Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DotNetAdapter_AspNetCore_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/app.csproj", "<Project><ItemGroup><PackageReference Include=\"Microsoft.AspNetCore\" /></ItemGroup></Project>");
|
||||
fs.AddFile("/app/Program.cs", "var builder = WebApplication.CreateBuilder(args);");
|
||||
|
||||
var context = CreateContext(fs, "/app/app.dll", "dotnet");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["project"] = "/app/app.csproj" } };
|
||||
var adapter = new DotNetSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("dotnet", result.Language);
|
||||
Assert.Equal("aspnetcore", result.Framework);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DotNetAdapter_WorkerService_InfersWorkerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/app.csproj", "<Project><ItemGroup><PackageReference Include=\"Microsoft.Extensions.Hosting\" /></ItemGroup></Project>");
|
||||
fs.AddFile("/app/Worker.cs", "public class Worker : BackgroundService");
|
||||
|
||||
var context = CreateContext(fs, "/app/app.dll", "dotnet");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["project"] = "/app/app.csproj" } };
|
||||
var adapter = new DotNetSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.Worker, result.Intent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DotNetAdapter_ConsoleApp_InfersCliToolIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/app.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\"><PropertyGroup><OutputType>Exe</OutputType></PropertyGroup></Project>");
|
||||
fs.AddFile("/app/Program.cs", "Console.WriteLine(args[0]);");
|
||||
|
||||
var context = CreateContext(fs, "/app/app.dll", "dotnet");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["project"] = "/app/app.csproj" } };
|
||||
var adapter = new DotNetSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.CliTool, result.Intent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Go Adapter Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GoAdapter_NetHttp_InfersWebServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/go.mod", "module example.com/app\ngo 1.21");
|
||||
fs.AddFile("/app/main.go", "package main\nimport \"net/http\"\nfunc main() { http.ListenAndServe(\":8080\", nil) }");
|
||||
|
||||
var context = CreateContext(fs, "/app/app", "go");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["go.mod"] = "/app/go.mod" } };
|
||||
var adapter = new GoSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.WebServer, result.Intent);
|
||||
Assert.Equal("go", result.Language);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GoAdapter_Cobra_InfersCliToolIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/go.mod", "module example.com/cli\ngo 1.21\nrequire github.com/spf13/cobra v1.7.0");
|
||||
fs.AddFile("/app/main.go", "package main\nimport \"github.com/spf13/cobra\"");
|
||||
|
||||
var context = CreateContext(fs, "/app/cli", "go");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["go.mod"] = "/app/go.mod" } };
|
||||
var adapter = new GoSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.CliTool, result.Intent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GoAdapter_Grpc_InfersRpcServerIntent()
|
||||
{
|
||||
// Arrange
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/go.mod", "module example.com/grpc\ngo 1.21\nrequire google.golang.org/grpc v1.58.0");
|
||||
|
||||
var context = CreateContext(fs, "/app/server", "go");
|
||||
context = context with { ManifestPaths = new Dictionary<string, string> { ["go.mod"] = "/app/go.mod" } };
|
||||
var adapter = new GoSemanticAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ApplicationIntent.RpcServer, result.Intent);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static SemanticAnalysisContext CreateContext(
|
||||
TestRootFileSystem fileSystem,
|
||||
string entrypointPath,
|
||||
string language)
|
||||
{
|
||||
return new SemanticAnalysisContext
|
||||
{
|
||||
Specification = new SemanticSpec
|
||||
{
|
||||
Entrypoint = ImmutableArray.Create(entrypointPath),
|
||||
},
|
||||
EntryTraceResult = null!,
|
||||
FileSystem = fileSystem,
|
||||
PrimaryLanguage = language,
|
||||
DetectedLanguages = new[] { language },
|
||||
ManifestPaths = new Dictionary<string, string>(),
|
||||
Dependencies = new Dictionary<string, IReadOnlyList<string>>(),
|
||||
ScanId = "test-scan-001"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.EntryTrace.FileSystem;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic;
|
||||
using StellaOps.Scanner.EntryTrace.Semantic.Analysis;
|
||||
using Xunit;
|
||||
|
||||
// Alias to distinguish from root namespace EntrypointSpecification
|
||||
using SemanticSpec = StellaOps.Scanner.EntryTrace.Semantic.EntrypointSpecification;
|
||||
|
||||
namespace StellaOps.Scanner.EntryTrace.Tests.Semantic;
|
||||
|
||||
/// <summary>
|
||||
/// Golden test suite validating semantic analysis determinism.
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 22).
|
||||
/// </summary>
|
||||
public sealed class SemanticDeterminismTests
|
||||
{
|
||||
private readonly SemanticEntrypointOrchestrator _orchestrator;
|
||||
|
||||
public SemanticDeterminismTests()
|
||||
{
|
||||
_orchestrator = new SemanticEntrypointOrchestrator();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Orchestrator_ProducesDeterministicResults_WhenRunMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var fs = CreateDeterministicFileSystem();
|
||||
var context = CreateContext(fs, "python");
|
||||
|
||||
// Act - run analysis 3 times
|
||||
var results = new List<SemanticAnalysisResult>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var result = await _orchestrator.AnalyzeAsync(context, CancellationToken.None);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert - all results should be identical
|
||||
for (var i = 1; i < results.Count; i++)
|
||||
{
|
||||
AssertResultsEqual(results[0], results[i], $"Run {i + 1} differs from run 1");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Orchestrator_ProducesConsistentIntent_AcrossIdenticalContexts()
|
||||
{
|
||||
// Arrange
|
||||
var fs1 = CreateDeterministicFileSystem();
|
||||
var fs2 = CreateDeterministicFileSystem();
|
||||
var context1 = CreateContext(fs1, "python");
|
||||
var context2 = CreateContext(fs2, "python");
|
||||
|
||||
// Act
|
||||
var result1 = await _orchestrator.AnalyzeAsync(context1, CancellationToken.None);
|
||||
var result2 = await _orchestrator.AnalyzeAsync(context2, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(result1.Entrypoint?.Intent, result2.Entrypoint?.Intent);
|
||||
Assert.Equal(result1.Entrypoint?.Capabilities, result2.Entrypoint?.Capabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Orchestrator_ProducesStableCapabilityOrder()
|
||||
{
|
||||
// Arrange
|
||||
var fs = CreateDeterministicFileSystem();
|
||||
var context = CreateContext(fs, "python");
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - capabilities should be ordered consistently (by flag value)
|
||||
if (result.Entrypoint is not null)
|
||||
{
|
||||
var caps = GetCapabilityList(result.Entrypoint.Capabilities);
|
||||
var sortedCaps = caps.OrderBy(c => (long)c).ToList();
|
||||
Assert.Equal(sortedCaps, caps);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Orchestrator_ProducesStableAttackSurfaceOrder()
|
||||
{
|
||||
// Arrange
|
||||
var fs = CreateWebServerFileSystem();
|
||||
var context = CreateContext(fs, "python");
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - attack surface should be ordered by threat type
|
||||
if (result.Entrypoint is not null && !result.Entrypoint.AttackSurface.IsDefaultOrEmpty)
|
||||
{
|
||||
var threats = result.Entrypoint.AttackSurface.Select(t => t.Type).ToList();
|
||||
var sortedThreats = threats.OrderBy(t => t).ToList();
|
||||
Assert.Equal(sortedThreats, threats);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Orchestrator_ProducesStableDataBoundaryOrder()
|
||||
{
|
||||
// Arrange
|
||||
var fs = CreateWebServerFileSystem();
|
||||
var context = CreateContext(fs, "python");
|
||||
|
||||
// Act
|
||||
var result = await _orchestrator.AnalyzeAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - data boundaries should be ordered consistently
|
||||
if (result.Entrypoint is not null && !result.Entrypoint.DataBoundaries.IsDefaultOrEmpty)
|
||||
{
|
||||
var boundaries = result.Entrypoint.DataBoundaries.Select(b => b.Type).ToList();
|
||||
// Verify no duplicates in same direction
|
||||
var grouped = result.Entrypoint.DataBoundaries
|
||||
.GroupBy(b => (b.Type, b.Direction))
|
||||
.Where(g => g.Count() > 1)
|
||||
.ToList();
|
||||
Assert.Empty(grouped);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapabilityDetector_ProducesDeterministicResults()
|
||||
{
|
||||
// Arrange
|
||||
var detector = new CapabilityDetector();
|
||||
var fs = CreateDeterministicFileSystem();
|
||||
var context = CreateContext(fs, "python");
|
||||
|
||||
// Act - run detection 3 times
|
||||
var results = new List<CapabilityDetectionResult>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var result = detector.Detect(context);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
for (var i = 1; i < results.Count; i++)
|
||||
{
|
||||
Assert.Equal(results[0].Capabilities, results[i].Capabilities);
|
||||
Assert.Equal(results[0].Evidence.Length, results[i].Evidence.Length);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThreatVectorInferrer_ProducesDeterministicResults()
|
||||
{
|
||||
// Arrange
|
||||
var inferrer = new ThreatVectorInferrer();
|
||||
var capabilities = CapabilityClass.NetworkListen | CapabilityClass.DatabaseSql | CapabilityClass.UserInput;
|
||||
var intent = ApplicationIntent.WebServer;
|
||||
var evidence = new List<CapabilityEvidence>
|
||||
{
|
||||
new() { Capability = CapabilityClass.NetworkListen, Confidence = 0.9, Source = EvidenceSource.Dependency, Artifact = "http" },
|
||||
new() { Capability = CapabilityClass.DatabaseSql, Confidence = 0.8, Source = EvidenceSource.Dependency, Artifact = "psycopg2" }
|
||||
};
|
||||
|
||||
// Act - run inference 3 times
|
||||
var results = new List<ThreatInferenceResult>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var result = inferrer.Infer(capabilities, intent, evidence);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
for (var i = 1; i < results.Count; i++)
|
||||
{
|
||||
Assert.Equal(results[0].ThreatVectors.Length, results[i].ThreatVectors.Length);
|
||||
Assert.Equal(results[0].OverallRiskScore, results[i].OverallRiskScore);
|
||||
|
||||
// Verify same threat types in same order
|
||||
for (var j = 0; j < results[0].ThreatVectors.Length; j++)
|
||||
{
|
||||
Assert.Equal(results[0].ThreatVectors[j].Type, results[i].ThreatVectors[j].Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataBoundaryMapper_ProducesDeterministicResults()
|
||||
{
|
||||
// Arrange
|
||||
var mapper = new DataBoundaryMapper();
|
||||
var fs = CreateWebServerFileSystem();
|
||||
var context = CreateContext(fs, "python");
|
||||
context = context with
|
||||
{
|
||||
Specification = context.Specification with
|
||||
{
|
||||
ExposedPorts = ImmutableArray.Create(80, 443)
|
||||
}
|
||||
};
|
||||
var capabilities = CapabilityClass.NetworkListen | CapabilityClass.DatabaseSql;
|
||||
var evidence = new List<CapabilityEvidence>();
|
||||
|
||||
// Act - run mapping 3 times
|
||||
var results = new List<DataBoundaryMappingResult>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var result = mapper.Map(context, ApplicationIntent.WebServer, capabilities, evidence);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
for (var i = 1; i < results.Count; i++)
|
||||
{
|
||||
Assert.Equal(results[0].Boundaries.Length, results[i].Boundaries.Length);
|
||||
Assert.Equal(results[0].InboundCount, results[i].InboundCount);
|
||||
Assert.Equal(results[0].OutboundCount, results[i].OutboundCount);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SemanticConfidence_CombinesConsistently()
|
||||
{
|
||||
// Arrange
|
||||
var confidences = new[]
|
||||
{
|
||||
SemanticConfidence.High("reason1"),
|
||||
SemanticConfidence.Medium("reason2"),
|
||||
SemanticConfidence.Low("reason3")
|
||||
};
|
||||
|
||||
// Act - combine 3 times
|
||||
var results = new List<SemanticConfidence>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var result = SemanticConfidence.Combine(confidences);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Assert
|
||||
for (var i = 1; i < results.Count; i++)
|
||||
{
|
||||
Assert.Equal(results[0].Score, results[i].Score);
|
||||
Assert.Equal(results[0].Tier, results[i].Tier);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SemanticEntrypoint_SerializesToDeterministicJson()
|
||||
{
|
||||
// Arrange
|
||||
var entrypoint = new SemanticEntrypoint
|
||||
{
|
||||
Id = "test-001",
|
||||
Specification = new SemanticSpec
|
||||
{
|
||||
Entrypoint = ImmutableArray.Create("/app/main.py"),
|
||||
ExposedPorts = ImmutableArray.Create(8080),
|
||||
},
|
||||
Intent = ApplicationIntent.WebServer,
|
||||
Capabilities = CapabilityClass.NetworkListen | CapabilityClass.DatabaseSql,
|
||||
AttackSurface = ImmutableArray.Create(
|
||||
new ThreatVector
|
||||
{
|
||||
Type = ThreatVectorType.SqlInjection,
|
||||
Confidence = 0.7,
|
||||
ContributingCapabilities = CapabilityClass.DatabaseSql,
|
||||
Evidence = ImmutableArray.Create("Uses raw SQL queries")
|
||||
}),
|
||||
DataBoundaries = ImmutableArray.Create(
|
||||
new DataFlowBoundary
|
||||
{
|
||||
Type = DataFlowBoundaryType.HttpRequest,
|
||||
Direction = DataFlowDirection.Inbound,
|
||||
Sensitivity = DataSensitivity.Internal,
|
||||
Confidence = 0.9,
|
||||
Evidence = ImmutableArray<string>.Empty
|
||||
}),
|
||||
Confidence = SemanticConfidence.High("Django detected"),
|
||||
Language = "python",
|
||||
Framework = "django",
|
||||
AnalyzedAt = "2024-01-01T00:00:00Z"
|
||||
};
|
||||
|
||||
// Act - serialize 3 times
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
var results = new List<string>();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(entrypoint, options);
|
||||
results.Add(json);
|
||||
}
|
||||
|
||||
// Assert - all serializations should be identical
|
||||
for (var i = 1; i < results.Count; i++)
|
||||
{
|
||||
Assert.Equal(results[0], results[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static TestRootFileSystem CreateDeterministicFileSystem()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/main.py", "#!/usr/bin/env python\nprint('hello')");
|
||||
fs.AddFile("/app/requirements.txt", "click==8.0.0\nrequests==2.28.0");
|
||||
return fs;
|
||||
}
|
||||
|
||||
private static TestRootFileSystem CreateWebServerFileSystem()
|
||||
{
|
||||
var fs = new TestRootFileSystem();
|
||||
fs.AddFile("/app/main.py", "#!/usr/bin/env python\nfrom flask import Flask\napp = Flask(__name__)");
|
||||
fs.AddFile("/app/requirements.txt", "flask==3.0.0\npsycopg2==2.9.0\nredis==5.0.0");
|
||||
fs.AddFile("/app/config.py", "DATABASE_URL = 'postgresql://...'");
|
||||
return fs;
|
||||
}
|
||||
|
||||
private static SemanticAnalysisContext CreateContext(TestRootFileSystem fs, string language)
|
||||
{
|
||||
return new SemanticAnalysisContext
|
||||
{
|
||||
Specification = new SemanticSpec
|
||||
{
|
||||
Entrypoint = ImmutableArray.Create("/app/main.py"),
|
||||
},
|
||||
EntryTraceResult = null!,
|
||||
FileSystem = fs,
|
||||
PrimaryLanguage = language,
|
||||
DetectedLanguages = new[] { language },
|
||||
ManifestPaths = new Dictionary<string, string>
|
||||
{
|
||||
["requirements.txt"] = "/app/requirements.txt"
|
||||
},
|
||||
Dependencies = new Dictionary<string, IReadOnlyList<string>>(),
|
||||
ScanId = "determinism-test"
|
||||
};
|
||||
}
|
||||
|
||||
private static void AssertResultsEqual(SemanticAnalysisResult expected, SemanticAnalysisResult actual, string message)
|
||||
{
|
||||
Assert.Equal(expected.Success, actual.Success);
|
||||
|
||||
if (expected.Entrypoint is not null && actual.Entrypoint is not null)
|
||||
{
|
||||
Assert.Equal(expected.Entrypoint.Intent, actual.Entrypoint.Intent);
|
||||
Assert.Equal(expected.Entrypoint.Capabilities, actual.Entrypoint.Capabilities);
|
||||
Assert.Equal(expected.Entrypoint.AttackSurface.Length, actual.Entrypoint.AttackSurface.Length);
|
||||
Assert.Equal(expected.Entrypoint.DataBoundaries.Length, actual.Entrypoint.DataBoundaries.Length);
|
||||
Assert.Equal(expected.Entrypoint.Confidence.Score, actual.Entrypoint.Confidence.Score);
|
||||
Assert.Equal(expected.Entrypoint.Confidence.Tier, actual.Entrypoint.Confidence.Tier);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(expected.Entrypoint is null, actual.Entrypoint is null);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CapabilityClass> GetCapabilityList(CapabilityClass caps)
|
||||
{
|
||||
var list = new List<CapabilityClass>();
|
||||
foreach (CapabilityClass flag in Enum.GetValues<CapabilityClass>())
|
||||
{
|
||||
if (flag != CapabilityClass.None && caps.HasFlag(flag))
|
||||
{
|
||||
list.Add(flag);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
public class EdgeBundleTests
|
||||
{
|
||||
private const string TestGraphHash = "blake3:abc123def456";
|
||||
|
||||
[Fact]
|
||||
public void EdgeBundle_Canonical_SortsEdgesDeterministically()
|
||||
{
|
||||
// Arrange - create bundle with unsorted edges
|
||||
var edges = new List<BundledEdge>
|
||||
{
|
||||
new("func_z", "func_a", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null),
|
||||
new("func_a", "func_c", "call", EdgeReason.RuntimeHit, false, 0.8, null, null, null),
|
||||
new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.7, null, null, null),
|
||||
};
|
||||
|
||||
var bundle = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var canonical = bundle.Canonical();
|
||||
|
||||
// Assert - edges should be sorted by From, then To, then Kind
|
||||
Assert.Equal(3, canonical.Edges.Count);
|
||||
Assert.Equal("func_a", canonical.Edges[0].From);
|
||||
Assert.Equal("func_b", canonical.Edges[0].To);
|
||||
Assert.Equal("func_a", canonical.Edges[1].From);
|
||||
Assert.Equal("func_c", canonical.Edges[1].To);
|
||||
Assert.Equal("func_z", canonical.Edges[2].From);
|
||||
Assert.Equal("func_a", canonical.Edges[2].To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeBundle_ComputeContentHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var edges = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null),
|
||||
new("func_b", "func_c", "call", EdgeReason.ThirdPartyCall, false, 0.8, null, null, null),
|
||||
};
|
||||
|
||||
var bundle1 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow);
|
||||
var bundle2 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
|
||||
// Act
|
||||
var hash1 = bundle1.ComputeContentHash();
|
||||
var hash2 = bundle2.ComputeContentHash();
|
||||
|
||||
// Assert - same content should produce same hash regardless of timestamp
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.StartsWith("sha256:", hash1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeBundle_ComputeContentHash_DiffersWithDifferentEdges()
|
||||
{
|
||||
// Arrange
|
||||
var edges1 = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null),
|
||||
};
|
||||
var edges2 = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_c", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null),
|
||||
};
|
||||
|
||||
var bundle1 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges1, DateTimeOffset.UtcNow);
|
||||
var bundle2 = new EdgeBundle("bundle:test", TestGraphHash, EdgeBundleReason.RuntimeHits, edges2, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var hash1 = bundle1.ComputeContentHash();
|
||||
var hash2 = bundle2.ComputeContentHash();
|
||||
|
||||
// Assert - different edges should produce different hashes
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeBundleBuilder_EnforcesMaxEdgeLimit()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new EdgeBundleBuilder(TestGraphHash).WithReason(EdgeBundleReason.RuntimeHits);
|
||||
|
||||
// Act - add max edges
|
||||
for (var i = 0; i < EdgeBundleConstants.MaxEdgesPerBundle; i++)
|
||||
{
|
||||
builder.AddEdge(new BundledEdge($"func_{i}", $"func_{i + 1}", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null));
|
||||
}
|
||||
|
||||
// Assert - should throw when exceeding limit
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
builder.AddEdge(new BundledEdge("func_overflow", "func_target", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeBundleBuilder_Build_CreatesDeterministicBundleId()
|
||||
{
|
||||
// Arrange
|
||||
var builder1 = new EdgeBundleBuilder(TestGraphHash).WithReason(EdgeBundleReason.InitArray);
|
||||
var builder2 = new EdgeBundleBuilder(TestGraphHash).WithReason(EdgeBundleReason.InitArray);
|
||||
|
||||
builder1.AddEdge(new BundledEdge("init_a", "func_b", "call", EdgeReason.InitArray, false, 1.0, null, null, null));
|
||||
builder2.AddEdge(new BundledEdge("init_a", "func_b", "call", EdgeReason.InitArray, false, 1.0, null, null, null));
|
||||
|
||||
// Act
|
||||
var bundle1 = builder1.Build();
|
||||
var bundle2 = builder2.Build();
|
||||
|
||||
// Assert - same inputs should produce same bundle ID
|
||||
Assert.Equal(bundle1.BundleId, bundle2.BundleId);
|
||||
Assert.StartsWith("bundle:", bundle1.BundleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundledEdge_Trimmed_NormalizesValues()
|
||||
{
|
||||
// Arrange
|
||||
var edge = new BundledEdge(
|
||||
From: " func_a ",
|
||||
To: " func_b ",
|
||||
Kind: " call ",
|
||||
Reason: EdgeReason.RuntimeHit,
|
||||
Revoked: false,
|
||||
Confidence: 1.5, // Should be clamped to 1.0
|
||||
Purl: " pkg:npm/test@1.0.0 ",
|
||||
SymbolDigest: " sha256:abc ",
|
||||
Evidence: " cas://evidence/123 ");
|
||||
|
||||
// Act
|
||||
var trimmed = edge.Trimmed();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("func_a", trimmed.From);
|
||||
Assert.Equal("func_b", trimmed.To);
|
||||
Assert.Equal("call", trimmed.Kind);
|
||||
Assert.Equal(1.0, trimmed.Confidence); // Clamped
|
||||
Assert.Equal("pkg:npm/test@1.0.0", trimmed.Purl);
|
||||
Assert.Equal("sha256:abc", trimmed.SymbolDigest);
|
||||
Assert.Equal("cas://evidence/123", trimmed.Evidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BundledEdge_Trimmed_HandlesNullableFields()
|
||||
{
|
||||
// Arrange
|
||||
var edge = new BundledEdge("func_a", "func_b", "", EdgeReason.RuntimeHit, false, 0.5, null, " ", null);
|
||||
|
||||
// Act
|
||||
var trimmed = edge.Trimmed();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("call", trimmed.Kind); // Default when empty
|
||||
Assert.Null(trimmed.Purl);
|
||||
Assert.Null(trimmed.SymbolDigest); // Whitespace trimmed to null
|
||||
Assert.Null(trimmed.Evidence);
|
||||
}
|
||||
}
|
||||
|
||||
public class EdgeBundleExtractorTests
|
||||
{
|
||||
private const string TestGraphHash = "blake3:abc123def456";
|
||||
|
||||
private static RichGraph CreateTestGraph(params RichGraphEdge[] edges)
|
||||
{
|
||||
var nodes = edges
|
||||
.SelectMany(e => new[] { e.From, e.To })
|
||||
.Distinct()
|
||||
.Select(id => new RichGraphNode(id, id, null, null, "native", "function", id, null, null, null, null, null, null))
|
||||
.ToList();
|
||||
|
||||
return new RichGraph(nodes, edges.ToList(), new List<RichGraphRoot>(), new RichGraphAnalyzer("test", "1.0", null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContestedBundle_ReturnsLowConfidenceEdges()
|
||||
{
|
||||
// Arrange
|
||||
var edges = new[]
|
||||
{
|
||||
new RichGraphEdge("func_a", "func_b", "call", null, null, null, 0.9, null),
|
||||
new RichGraphEdge("func_b", "func_c", "call", null, null, null, 0.4, null), // Low confidence
|
||||
new RichGraphEdge("func_c", "func_d", "call", null, null, null, 0.3, null), // Low confidence
|
||||
};
|
||||
var graph = CreateTestGraph(edges);
|
||||
|
||||
// Act
|
||||
var bundle = EdgeBundleExtractor.ExtractContestedBundle(graph, TestGraphHash, confidenceThreshold: 0.5);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal(EdgeBundleReason.Contested, bundle.BundleReason);
|
||||
Assert.Equal(2, bundle.Edges.Count);
|
||||
Assert.All(bundle.Edges, e => Assert.Equal(EdgeReason.LowConfidence, e.Reason));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractContestedBundle_ReturnsNullWhenNoLowConfidenceEdges()
|
||||
{
|
||||
// Arrange
|
||||
var edges = new[]
|
||||
{
|
||||
new RichGraphEdge("func_a", "func_b", "call", null, null, null, 0.9, null),
|
||||
new RichGraphEdge("func_b", "func_c", "call", null, null, null, 0.8, null),
|
||||
};
|
||||
var graph = CreateTestGraph(edges);
|
||||
|
||||
// Act
|
||||
var bundle = EdgeBundleExtractor.ExtractContestedBundle(graph, TestGraphHash, confidenceThreshold: 0.5);
|
||||
|
||||
// Assert
|
||||
Assert.Null(bundle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractThirdPartyBundle_ReturnsEdgesWithPurl()
|
||||
{
|
||||
// Arrange
|
||||
var edges = new[]
|
||||
{
|
||||
new RichGraphEdge("func_a", "func_b", "call", "pkg:npm/lodash@4.17.0", null, null, 0.9, null),
|
||||
new RichGraphEdge("func_b", "func_c", "call", "pkg:unknown", null, null, 0.8, null), // Excluded
|
||||
new RichGraphEdge("func_c", "func_d", "call", null, null, null, 0.7, null), // Excluded
|
||||
new RichGraphEdge("func_d", "func_e", "call", "pkg:npm/express@4.0.0", null, null, 0.9, null),
|
||||
};
|
||||
var graph = CreateTestGraph(edges);
|
||||
|
||||
// Act
|
||||
var bundle = EdgeBundleExtractor.ExtractThirdPartyBundle(graph, TestGraphHash);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal(EdgeBundleReason.ThirdParty, bundle.BundleReason);
|
||||
Assert.Equal(2, bundle.Edges.Count);
|
||||
Assert.All(bundle.Edges, e => Assert.Equal(EdgeReason.ThirdPartyCall, e.Reason));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractRevokedBundle_ReturnsEdgesToRevokedTargets()
|
||||
{
|
||||
// Arrange
|
||||
var edges = new[]
|
||||
{
|
||||
new RichGraphEdge("func_a", "func_b", "call", null, null, null, 0.9, null),
|
||||
new RichGraphEdge("func_b", "func_c", "call", null, null, null, 0.8, null),
|
||||
new RichGraphEdge("func_c", "func_d", "call", null, null, null, 0.7, null),
|
||||
};
|
||||
var graph = CreateTestGraph(edges);
|
||||
var revokedTargets = new HashSet<string> { "func_c", "func_d" };
|
||||
|
||||
// Act
|
||||
var bundle = EdgeBundleExtractor.ExtractRevokedBundle(graph, TestGraphHash, revokedTargets);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal(EdgeBundleReason.Revoked, bundle.BundleReason);
|
||||
Assert.Equal(2, bundle.Edges.Count);
|
||||
Assert.All(bundle.Edges, e =>
|
||||
{
|
||||
Assert.Equal(EdgeReason.Revoked, e.Reason);
|
||||
Assert.True(e.Revoked);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractRuntimeHitsBundle_ReturnsProvidedEdges()
|
||||
{
|
||||
// Arrange
|
||||
var runtimeEdges = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 1.0, null, null, "evidence_1"),
|
||||
new("func_b", "func_c", "call", EdgeReason.RuntimeHit, false, 1.0, null, null, "evidence_2"),
|
||||
};
|
||||
|
||||
// Act
|
||||
var bundle = EdgeBundleExtractor.ExtractRuntimeHitsBundle(runtimeEdges, TestGraphHash);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal(EdgeBundleReason.RuntimeHits, bundle.BundleReason);
|
||||
Assert.Equal(2, bundle.Edges.Count);
|
||||
Assert.All(bundle.Edges, e => Assert.Equal(EdgeReason.RuntimeHit, e.Reason));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractRuntimeHitsBundle_ReturnsNullForEmptyList()
|
||||
{
|
||||
// Act
|
||||
var bundle = EdgeBundleExtractor.ExtractRuntimeHitsBundle(new List<BundledEdge>(), TestGraphHash);
|
||||
|
||||
// Assert
|
||||
Assert.Null(bundle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractInitArrayBundle_ReturnsEdgesFromInitRoots()
|
||||
{
|
||||
// Arrange
|
||||
var edges = new[]
|
||||
{
|
||||
new RichGraphEdge("init_func", "target_a", "call", null, null, null, 1.0, null),
|
||||
new RichGraphEdge("init_func", "target_b", "call", null, null, null, 1.0, null),
|
||||
new RichGraphEdge("main_func", "target_c", "call", null, null, null, 0.9, null), // Not from init
|
||||
};
|
||||
var nodes = edges
|
||||
.SelectMany(e => new[] { e.From, e.To })
|
||||
.Distinct()
|
||||
.Select(id => new RichGraphNode(id, id, null, null, "native", "function", id, null, null, null, null, null, null))
|
||||
.ToList();
|
||||
var roots = new List<RichGraphRoot>
|
||||
{
|
||||
new("init_func", "init", ".init_array")
|
||||
};
|
||||
var graph = new RichGraph(nodes, edges.ToList(), roots, new RichGraphAnalyzer("test", "1.0", null));
|
||||
|
||||
// Act
|
||||
var bundle = EdgeBundleExtractor.ExtractInitArrayBundle(graph, TestGraphHash);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal(EdgeBundleReason.InitArray, bundle.BundleReason);
|
||||
Assert.Equal(2, bundle.Edges.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public class EdgeBundlePublisherTests
|
||||
{
|
||||
private const string TestGraphHash = "blake3:abc123def456";
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_StoresBundleAndDsseInCas()
|
||||
{
|
||||
// Arrange
|
||||
var cas = new FakeFileContentAddressableStore();
|
||||
var publisher = new EdgeBundlePublisher();
|
||||
|
||||
var edges = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, "pkg:npm/test@1.0.0", "sha256:abc", null),
|
||||
};
|
||||
var bundle = new EdgeBundle("bundle:test123", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var result = await publisher.PublishAsync(bundle, cas);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("bundle:test123", result.BundleId);
|
||||
Assert.Equal(TestGraphHash, result.GraphHash);
|
||||
Assert.Equal(EdgeBundleReason.RuntimeHits, result.BundleReason);
|
||||
Assert.Equal(1, result.EdgeCount);
|
||||
Assert.StartsWith("sha256:", result.ContentHash);
|
||||
Assert.StartsWith("sha256:", result.DsseDigest);
|
||||
|
||||
// Verify CAS paths
|
||||
Assert.Contains("/edges/", result.CasUri);
|
||||
Assert.Contains("/edges/", result.DsseCasUri);
|
||||
Assert.EndsWith(".dsse", result.DsseCasUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_DsseContainsValidPayload()
|
||||
{
|
||||
// Arrange
|
||||
var cas = new FakeFileContentAddressableStore();
|
||||
var publisher = new EdgeBundlePublisher();
|
||||
|
||||
var edges = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null),
|
||||
new("func_b", "func_c", "call", EdgeReason.ThirdPartyCall, true, 0.8, null, null, null),
|
||||
};
|
||||
var bundle = new EdgeBundle("bundle:test456", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var result = await publisher.PublishAsync(bundle, cas);
|
||||
|
||||
// Assert - verify DSSE was stored
|
||||
var dsseKey = result.DsseRelativePath.Replace(".zip", "");
|
||||
var dsseBytes = cas.GetBytes(dsseKey);
|
||||
Assert.NotNull(dsseBytes);
|
||||
|
||||
// Parse DSSE envelope
|
||||
var dsseJson = System.Text.Encoding.UTF8.GetString(dsseBytes);
|
||||
var envelope = JsonDocument.Parse(dsseJson);
|
||||
|
||||
Assert.Equal("application/vnd.stellaops.edgebundle.predicate+json", envelope.RootElement.GetProperty("payloadType").GetString());
|
||||
Assert.True(envelope.RootElement.TryGetProperty("payload", out _));
|
||||
Assert.True(envelope.RootElement.TryGetProperty("signatures", out var signatures));
|
||||
Assert.Single(signatures.EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_BundleJsonContainsAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var cas = new FakeFileContentAddressableStore();
|
||||
var publisher = new EdgeBundlePublisher();
|
||||
|
||||
var edges = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.Revoked, true, 0.5, "pkg:npm/test@1.0.0", "sha256:digest", "cas://evidence/123"),
|
||||
};
|
||||
var bundle = new EdgeBundle("bundle:revoked", TestGraphHash, EdgeBundleReason.Revoked, edges, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var result = await publisher.PublishAsync(bundle, cas);
|
||||
|
||||
// Assert - verify bundle JSON was stored
|
||||
var bundleKey = result.RelativePath.Replace(".zip", "");
|
||||
var bundleBytes = cas.GetBytes(bundleKey);
|
||||
Assert.NotNull(bundleBytes);
|
||||
|
||||
// Parse bundle JSON
|
||||
var bundleJsonStr = System.Text.Encoding.UTF8.GetString(bundleBytes);
|
||||
var bundleJson = JsonDocument.Parse(bundleJsonStr);
|
||||
|
||||
Assert.Equal("edge-bundle-v1", bundleJson.RootElement.GetProperty("schema").GetString());
|
||||
Assert.Equal("Revoked", bundleJson.RootElement.GetProperty("bundleReason").GetString());
|
||||
|
||||
var edgesArray = bundleJson.RootElement.GetProperty("edges");
|
||||
Assert.Single(edgesArray.EnumerateArray());
|
||||
|
||||
var edge = edgesArray[0];
|
||||
Assert.Equal("func_a", edge.GetProperty("from").GetString());
|
||||
Assert.Equal("func_b", edge.GetProperty("to").GetString());
|
||||
Assert.Equal("Revoked", edge.GetProperty("reason").GetString());
|
||||
Assert.True(edge.GetProperty("revoked").GetBoolean());
|
||||
Assert.Equal("pkg:npm/test@1.0.0", edge.GetProperty("purl").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_CasPathFollowsContract()
|
||||
{
|
||||
// Arrange
|
||||
var cas = new FakeFileContentAddressableStore();
|
||||
var publisher = new EdgeBundlePublisher();
|
||||
|
||||
var edges = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.InitArray, false, 1.0, null, null, null),
|
||||
};
|
||||
var bundle = new EdgeBundle("bundle:init123", TestGraphHash, EdgeBundleReason.InitArray, edges, DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var result = await publisher.PublishAsync(bundle, cas);
|
||||
|
||||
// Assert - CAS path follows contract: cas://reachability/edges/{graph_hash}/{bundle_id}
|
||||
var expectedGraphHashDigest = "abc123def456"; // Graph hash without prefix
|
||||
Assert.StartsWith($"cas://reachability/edges/{expectedGraphHashDigest}/", result.CasUri);
|
||||
Assert.StartsWith($"cas://reachability/edges/{expectedGraphHashDigest}/", result.DsseCasUri);
|
||||
Assert.EndsWith(".dsse", result.DsseCasUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishAsync_ProducesDeterministicResults()
|
||||
{
|
||||
// Arrange
|
||||
var cas1 = new FakeFileContentAddressableStore();
|
||||
var cas2 = new FakeFileContentAddressableStore();
|
||||
var publisher = new EdgeBundlePublisher();
|
||||
|
||||
var edges = new List<BundledEdge>
|
||||
{
|
||||
new("func_a", "func_b", "call", EdgeReason.RuntimeHit, false, 0.9, null, null, null),
|
||||
};
|
||||
var bundle1 = new EdgeBundle("bundle:det", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow);
|
||||
var bundle2 = new EdgeBundle("bundle:det", TestGraphHash, EdgeBundleReason.RuntimeHits, edges, DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
// Act
|
||||
var result1 = await publisher.PublishAsync(bundle1, cas1);
|
||||
var result2 = await publisher.PublishAsync(bundle2, cas2);
|
||||
|
||||
// Assert - content hash should be same for same content
|
||||
Assert.Equal(result1.ContentHash, result2.ContentHash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user