Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -42,6 +42,7 @@ internal static class CommandFactory
|
||||
root.Add(BuildRubyCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPhpCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPythonCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildBunCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
|
||||
@@ -72,10 +73,10 @@ internal static class CommandFactory
|
||||
root.Add(BuildRiskCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildReachabilityCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildApiCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildSdkCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildMirrorCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAirgapCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(SystemCommandBuilder.BuildSystemCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
|
||||
@@ -370,6 +371,74 @@ internal static class CommandFactory
|
||||
return python;
|
||||
}
|
||||
|
||||
private static Command BuildBunCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var bun = new Command("bun", "Work with Bun analyzer outputs.");
|
||||
|
||||
var inspect = new Command("inspect", "Inspect a local Bun workspace.");
|
||||
var inspectRootOption = new Option<string?>("--root")
|
||||
{
|
||||
Description = "Path to the Bun workspace (defaults to current directory)."
|
||||
};
|
||||
var inspectFormatOption = new Option<string?>("--format")
|
||||
{
|
||||
Description = "Output format (table or json)."
|
||||
};
|
||||
|
||||
inspect.Add(inspectRootOption);
|
||||
inspect.Add(inspectFormatOption);
|
||||
inspect.SetAction((parseResult, _) =>
|
||||
{
|
||||
var root = parseResult.GetValue(inspectRootOption);
|
||||
var format = parseResult.GetValue(inspectFormatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleBunInspectAsync(
|
||||
services,
|
||||
root,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var resolve = new Command("resolve", "Fetch Bun packages for a completed scan.");
|
||||
var resolveImageOption = new Option<string?>("--image")
|
||||
{
|
||||
Description = "Image reference (digest or tag) used by the scan."
|
||||
};
|
||||
var resolveScanIdOption = new Option<string?>("--scan-id")
|
||||
{
|
||||
Description = "Explicit scan identifier."
|
||||
};
|
||||
var resolveFormatOption = new Option<string?>("--format")
|
||||
{
|
||||
Description = "Output format (table or json)."
|
||||
};
|
||||
|
||||
resolve.Add(resolveImageOption);
|
||||
resolve.Add(resolveScanIdOption);
|
||||
resolve.Add(resolveFormatOption);
|
||||
resolve.SetAction((parseResult, _) =>
|
||||
{
|
||||
var image = parseResult.GetValue(resolveImageOption);
|
||||
var scanId = parseResult.GetValue(resolveScanIdOption);
|
||||
var format = parseResult.GetValue(resolveFormatOption) ?? "table";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleBunResolveAsync(
|
||||
services,
|
||||
image,
|
||||
scanId,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
bun.Add(inspect);
|
||||
bun.Add(resolve);
|
||||
return bun;
|
||||
}
|
||||
|
||||
private static Command BuildKmsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var kms = new Command("kms", "Manage file-backed signing keys.");
|
||||
|
||||
@@ -29,6 +29,7 @@ using StellaOps.Cli.Prompts;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
@@ -40,6 +41,7 @@ using StellaOps.Scanner.Analyzers.Lang.Node;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Python;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Php;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Bun;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
@@ -8327,6 +8329,191 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleBunInspectAsync(
|
||||
IServiceProvider services,
|
||||
string? rootPath,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-inspect");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.inspect", ActivityKind.Internal);
|
||||
activity?.SetTag("stellaops.cli.command", "bun inspect");
|
||||
using var duration = CliMetrics.MeasureCommandDuration("bun inspect");
|
||||
|
||||
var outcome = "unknown";
|
||||
try
|
||||
{
|
||||
var normalizedFormat = string.IsNullOrWhiteSpace(format)
|
||||
? "table"
|
||||
: format.Trim().ToLowerInvariant();
|
||||
if (normalizedFormat is not ("table" or "json"))
|
||||
{
|
||||
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
|
||||
}
|
||||
|
||||
var targetRoot = string.IsNullOrWhiteSpace(rootPath)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(rootPath);
|
||||
if (!Directory.Exists(targetRoot))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Directory '{targetRoot}' was not found.");
|
||||
}
|
||||
|
||||
logger.LogInformation("Inspecting Bun workspace in {Root}.", targetRoot);
|
||||
activity?.SetTag("stellaops.cli.bun.root", targetRoot);
|
||||
|
||||
var engine = new LanguageAnalyzerEngine(new ILanguageAnalyzer[] { new BunLanguageAnalyzer() });
|
||||
var context = new LanguageAnalyzerContext(targetRoot, TimeProvider.System);
|
||||
var result = await engine.AnalyzeAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var report = BunInspectReport.Create(result.ToSnapshots());
|
||||
|
||||
activity?.SetTag("stellaops.cli.bun.package_count", report.Packages.Count);
|
||||
|
||||
if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderBunInspectReport(report);
|
||||
}
|
||||
|
||||
outcome = report.Packages.Count == 0 ? "empty" : "ok";
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (DirectoryNotFoundException ex)
|
||||
{
|
||||
outcome = "not_found";
|
||||
logger.LogError(ex.Message);
|
||||
Environment.ExitCode = 71;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
outcome = "invalid";
|
||||
logger.LogError(ex.Message);
|
||||
Environment.ExitCode = 64;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
outcome = "error";
|
||||
logger.LogError(ex, "Bun inspect failed.");
|
||||
Environment.ExitCode = 70;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
CliMetrics.RecordBunInspect(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleBunResolveAsync(
|
||||
IServiceProvider services,
|
||||
string? imageReference,
|
||||
string? scanId,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("bun-resolve");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.bun.resolve", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "bun resolve");
|
||||
using var duration = CliMetrics.MeasureCommandDuration("bun resolve");
|
||||
|
||||
var outcome = "unknown";
|
||||
try
|
||||
{
|
||||
var normalizedFormat = string.IsNullOrWhiteSpace(format)
|
||||
? "table"
|
||||
: format.Trim().ToLowerInvariant();
|
||||
if (normalizedFormat is not ("table" or "json"))
|
||||
{
|
||||
throw new InvalidOperationException("Format must be either 'table' or 'json'.");
|
||||
}
|
||||
|
||||
var identifier = !string.IsNullOrWhiteSpace(scanId)
|
||||
? scanId!.Trim()
|
||||
: imageReference?.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
throw new InvalidOperationException("An --image or --scan-id value is required.");
|
||||
}
|
||||
|
||||
logger.LogInformation("Resolving Bun packages for scan {ScanId}.", identifier);
|
||||
activity?.SetTag("stellaops.cli.scan_id", identifier);
|
||||
|
||||
var inventory = await client.GetBunPackagesAsync(identifier, cancellationToken).ConfigureAwait(false);
|
||||
if (inventory is null)
|
||||
{
|
||||
outcome = "empty";
|
||||
Environment.ExitCode = 0;
|
||||
AnsiConsole.MarkupLine("[yellow]Bun package inventory is not available for scan {0}.[/]", Markup.Escape(identifier));
|
||||
return;
|
||||
}
|
||||
|
||||
var report = BunResolveReport.Create(inventory);
|
||||
|
||||
if (!report.HasPackages)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No Bun packages found for scan {0}.[/]", Markup.Escape(identifier));
|
||||
}
|
||||
else if (string.Equals(normalizedFormat, "json", StringComparison.Ordinal))
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
Console.WriteLine(JsonSerializer.Serialize(report, options));
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderBunResolveReport(report);
|
||||
}
|
||||
|
||||
outcome = report.HasPackages ? "ok" : "empty";
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
outcome = "invalid";
|
||||
logger.LogError(ex.Message);
|
||||
Environment.ExitCode = 64;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
outcome = "network_error";
|
||||
logger.LogError(ex, "Failed to resolve Bun packages.");
|
||||
Environment.ExitCode = 69;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
outcome = "error";
|
||||
logger.LogError(ex, "Bun resolve failed.");
|
||||
Environment.ExitCode = 70;
|
||||
}
|
||||
finally
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
CliMetrics.RecordBunResolve(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderPythonInspectReport(IReadOnlyList<LanguageComponentSnapshot> snapshots)
|
||||
{
|
||||
if (snapshots.Count == 0)
|
||||
@@ -8384,6 +8571,64 @@ internal static class CommandHandlers
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
private static void RenderBunInspectReport(BunInspectReport report)
|
||||
{
|
||||
if (!report.Packages.Any())
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No Bun packages detected.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table().Border(TableBorder.Rounded);
|
||||
table.AddColumn("Package");
|
||||
table.AddColumn("Version");
|
||||
table.AddColumn("Source");
|
||||
table.AddColumn("Dev");
|
||||
table.AddColumn("Direct");
|
||||
|
||||
foreach (var entry in report.Packages)
|
||||
{
|
||||
var dev = entry.IsDev ? "[grey]yes[/]" : "-";
|
||||
var direct = entry.IsDirect ? "[blue]yes[/]" : "-";
|
||||
table.AddRow(
|
||||
Markup.Escape(entry.Name),
|
||||
Markup.Escape(entry.Version ?? "-"),
|
||||
Markup.Escape(entry.Source ?? "-"),
|
||||
dev,
|
||||
direct);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.MarkupLine($"[grey]Total packages: {report.Packages.Count}[/]");
|
||||
}
|
||||
|
||||
private static void RenderBunResolveReport(BunResolveReport report)
|
||||
{
|
||||
if (!report.HasPackages)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]No Bun packages found.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table().Border(TableBorder.Rounded);
|
||||
table.AddColumn("Package");
|
||||
table.AddColumn("Version");
|
||||
table.AddColumn("Source");
|
||||
table.AddColumn("Integrity");
|
||||
|
||||
foreach (var entry in report.Packages)
|
||||
{
|
||||
table.AddRow(
|
||||
Markup.Escape(entry.Name),
|
||||
Markup.Escape(entry.Version ?? "-"),
|
||||
Markup.Escape(entry.Source ?? "-"),
|
||||
Markup.Escape(entry.Integrity ?? "-"));
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.MarkupLine($"[grey]Scan: {Markup.Escape(report.ScanId ?? "-")} • Total: {report.Packages.Count}[/]");
|
||||
}
|
||||
|
||||
private static void RenderRubyInspectReport(RubyInspectReport report)
|
||||
{
|
||||
if (!report.Packages.Any())
|
||||
@@ -8999,6 +9244,163 @@ internal static class CommandHandlers
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BunInspectReport
|
||||
{
|
||||
[JsonPropertyName("packages")]
|
||||
public IReadOnlyList<BunInspectEntry> Packages { get; }
|
||||
|
||||
private BunInspectReport(IReadOnlyList<BunInspectEntry> packages)
|
||||
{
|
||||
Packages = packages;
|
||||
}
|
||||
|
||||
public static BunInspectReport Create(IEnumerable<LanguageComponentSnapshot>? snapshots)
|
||||
{
|
||||
var source = snapshots?.ToArray() ?? Array.Empty<LanguageComponentSnapshot>();
|
||||
|
||||
var entries = source
|
||||
.Where(static snapshot => string.Equals(snapshot.Type, "npm", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(BunInspectEntry.FromSnapshot)
|
||||
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return new BunInspectReport(entries);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BunInspectEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("isDev")] bool IsDev,
|
||||
[property: JsonPropertyName("isDirect")] bool IsDirect,
|
||||
[property: JsonPropertyName("resolved")] string? Resolved,
|
||||
[property: JsonPropertyName("integrity")] string? Integrity)
|
||||
{
|
||||
public static BunInspectEntry FromSnapshot(LanguageComponentSnapshot snapshot)
|
||||
{
|
||||
var metadata = BunMetadataHelpers.Clone(snapshot.Metadata);
|
||||
var source = BunMetadataHelpers.GetString(metadata, "source");
|
||||
var isDev = BunMetadataHelpers.GetBool(metadata, "dev") ?? false;
|
||||
var isDirect = BunMetadataHelpers.GetBool(metadata, "direct") ?? false;
|
||||
var resolved = BunMetadataHelpers.GetString(metadata, "resolved");
|
||||
var integrity = BunMetadataHelpers.GetString(metadata, "integrity");
|
||||
|
||||
return new BunInspectEntry(
|
||||
snapshot.Name ?? "-",
|
||||
snapshot.Version,
|
||||
source,
|
||||
isDev,
|
||||
isDirect,
|
||||
resolved,
|
||||
integrity);
|
||||
}
|
||||
}
|
||||
|
||||
private static class BunMetadataHelpers
|
||||
{
|
||||
public static IDictionary<string, string?> Clone(IDictionary<string, string?>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var clone = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
clone[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public static string? GetString(IDictionary<string, string?> metadata, string key)
|
||||
{
|
||||
if (metadata.TryGetValue(key, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
if (string.Equals(pair.Key, key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool? GetBool(IDictionary<string, string?> metadata, string key)
|
||||
{
|
||||
var value = GetString(metadata, key);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BunResolveReport
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string? ScanId { get; }
|
||||
|
||||
[JsonPropertyName("packages")]
|
||||
public IReadOnlyList<BunResolveEntry> Packages { get; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasPackages => Packages.Count > 0;
|
||||
|
||||
private BunResolveReport(string? scanId, IReadOnlyList<BunResolveEntry> packages)
|
||||
{
|
||||
ScanId = scanId;
|
||||
Packages = packages;
|
||||
}
|
||||
|
||||
public static BunResolveReport Create(BunPackageInventory? inventory)
|
||||
{
|
||||
if (inventory is null)
|
||||
{
|
||||
return new BunResolveReport(null, Array.Empty<BunResolveEntry>());
|
||||
}
|
||||
|
||||
var entries = inventory.Packages
|
||||
.Select(BunResolveEntry.FromPackage)
|
||||
.OrderBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static entry => entry.Version ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
return new BunResolveReport(inventory.ScanId, entries);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BunResolveEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("integrity")] string? Integrity)
|
||||
{
|
||||
public static BunResolveEntry FromPackage(BunPackageItem package)
|
||||
{
|
||||
return new BunResolveEntry(
|
||||
package.Name,
|
||||
package.Version,
|
||||
package.Source,
|
||||
package.Integrity);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record LockValidationEntry(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
|
||||
@@ -20,6 +20,7 @@ using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
@@ -960,6 +961,50 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BunPackageInventory?> GetBunPackagesAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scanId))
|
||||
{
|
||||
throw new ArgumentException("Scan identifier is required.", nameof(scanId));
|
||||
}
|
||||
|
||||
var encodedScanId = Uri.EscapeDataString(scanId);
|
||||
using var request = CreateRequest(HttpMethod.Get, $"api/scans/{encodedScanId}/bun-packages");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
var inventory = await response.Content
|
||||
.ReadFromJsonAsync<BunPackageInventory>(SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (inventory is null)
|
||||
{
|
||||
throw new InvalidOperationException("Bun package response payload was empty.");
|
||||
}
|
||||
|
||||
var normalizedScanId = string.IsNullOrWhiteSpace(inventory.ScanId) ? scanId : inventory.ScanId;
|
||||
var packages = inventory.Packages ?? Array.Empty<BunPackageItem>();
|
||||
|
||||
return inventory with
|
||||
{
|
||||
ScanId = normalizedScanId,
|
||||
Packages = packages
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
|
||||
AdvisoryAiTaskType taskType,
|
||||
AdvisoryPipelinePlanRequestModel request,
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -51,6 +52,8 @@ internal interface IBackendOperationsClient
|
||||
|
||||
Task<RubyPackageInventoryModel?> GetRubyPackagesAsync(string scanId, CancellationToken cancellationToken);
|
||||
|
||||
Task<BunPackageInventory?> GetBunPackagesAsync(string scanId, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.Bun;
|
||||
|
||||
internal sealed record BunPackageItem(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("resolved")] string? Resolved,
|
||||
[property: JsonPropertyName("integrity")] string? Integrity,
|
||||
[property: JsonPropertyName("isDev")] bool? IsDev,
|
||||
[property: JsonPropertyName("isDirect")] bool? IsDirect,
|
||||
[property: JsonPropertyName("isPatched")] bool? IsPatched,
|
||||
[property: JsonPropertyName("customRegistry")] string? CustomRegistry,
|
||||
[property: JsonPropertyName("metadata")] IDictionary<string, string?>? Metadata);
|
||||
|
||||
internal sealed record BunPackageInventory(
|
||||
[property: JsonPropertyName("scanId")] string ScanId,
|
||||
[property: JsonPropertyName("imageDigest")] string? ImageDigest,
|
||||
[property: JsonPropertyName("generatedAt")] DateTimeOffset? GeneratedAt,
|
||||
[property: JsonPropertyName("packages")] IReadOnlyList<BunPackageItem> Packages);
|
||||
@@ -53,6 +53,7 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Ruby/StellaOps.Scanner.Analyzers.Lang.Ruby.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Java/StellaOps.Scanner.Analyzers.Lang.Java.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/StellaOps.Scanner.Analyzers.Lang.Php.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Bun/StellaOps.Scanner.Analyzers.Lang.Bun.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../../Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
@@ -61,6 +62,12 @@
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="../../Attestor/StellaOps.Attestor.Envelope/StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Authority/__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Scheduler/__Libraries/StellaOps.Scheduler.Storage.Postgres/StellaOps.Scheduler.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Concelier/__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Policy/__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Notify/__Libraries/StellaOps.Notify.Storage.Postgres/StellaOps.Notify.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../../Excititor/__Libraries/StellaOps.Excititor.Storage.Postgres/StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(StellaOpsEnableCryptoPro)' == 'true'">
|
||||
|
||||
@@ -62,6 +62,8 @@ internal static class CliMetrics
|
||||
private static readonly Counter<long> RubyResolveCounter = Meter.CreateCounter<long>("stellaops.cli.ruby.resolve.count");
|
||||
private static readonly Counter<long> PhpInspectCounter = Meter.CreateCounter<long>("stellaops.cli.php.inspect.count");
|
||||
private static readonly Counter<long> PythonInspectCounter = Meter.CreateCounter<long>("stellaops.cli.python.inspect.count");
|
||||
private static readonly Counter<long> BunInspectCounter = Meter.CreateCounter<long>("stellaops.cli.bun.inspect.count");
|
||||
private static readonly Counter<long> BunResolveCounter = Meter.CreateCounter<long>("stellaops.cli.bun.resolve.count");
|
||||
private static readonly Counter<long> AttestSignCounter = Meter.CreateCounter<long>("stellaops.cli.attest.sign.count");
|
||||
private static readonly Counter<long> AttestVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.attest.verify.count");
|
||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||
@@ -153,6 +155,14 @@ internal static class CliMetrics
|
||||
=> PythonInspectCounter.Add(1, WithSealedModeTag(
|
||||
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
|
||||
|
||||
public static void RecordBunInspect(string outcome)
|
||||
=> BunInspectCounter.Add(1, WithSealedModeTag(
|
||||
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
|
||||
|
||||
public static void RecordBunResolve(string outcome)
|
||||
=> BunResolveCounter.Add(1, WithSealedModeTag(
|
||||
Tag("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)));
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful attestation signing operation (CLI-ATTEST-73-001).
|
||||
/// </summary>
|
||||
|
||||
@@ -23,4 +23,17 @@ public sealed class CommandFactoryTests
|
||||
Assert.Contains(ruby.Subcommands, command => string.Equals(command.Name, "inspect", StringComparison.Ordinal));
|
||||
Assert.Contains(ruby.Subcommands, command => string.Equals(command.Name, "resolve", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ExposesBunInspectAndResolveCommands()
|
||||
{
|
||||
using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var root = CommandFactory.Create(services, new StellaOpsCliOptions(), CancellationToken.None, loggerFactory);
|
||||
|
||||
var bun = Assert.Single(root.Subcommands, command => string.Equals(command.Name, "bun", StringComparison.Ordinal));
|
||||
|
||||
Assert.Contains(bun.Subcommands, command => string.Equals(command.Name, "inspect", StringComparison.Ordinal));
|
||||
Assert.Contains(bun.Subcommands, command => string.Equals(command.Name, "resolve", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Bun;
|
||||
using StellaOps.Cli.Services.Models.Ruby;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
@@ -641,6 +642,161 @@ public sealed class CommandHandlersTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleBunInspectAsync_WritesJson()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
using var fixture = new TempDirectory();
|
||||
CreateBunWorkspace(fixture.Path);
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)));
|
||||
|
||||
try
|
||||
{
|
||||
var output = await CaptureTestConsoleAsync(async _ =>
|
||||
{
|
||||
await CommandHandlers.HandleBunInspectAsync(
|
||||
provider,
|
||||
fixture.Path,
|
||||
"json",
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
using var document = JsonDocument.Parse(output.PlainBuffer);
|
||||
var packages = document.RootElement.GetProperty("packages");
|
||||
Assert.NotEmpty(packages.EnumerateArray());
|
||||
|
||||
Assert.Contains(packages.EnumerateArray(), p =>
|
||||
string.Equals(p.GetProperty("name").GetString(), "lodash", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Contains(packages.EnumerateArray(), p =>
|
||||
string.Equals(p.GetProperty("name").GetString(), "express", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleBunResolveAsync_RendersPackages()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||
{
|
||||
BunInventory = CreateBunInventory(
|
||||
"scan-bun",
|
||||
new[]
|
||||
{
|
||||
CreateBunPackageItem("lodash", "4.17.21", isDev: false, isDirect: true),
|
||||
CreateBunPackageItem("express", "4.18.2", isDev: false, isDirect: true),
|
||||
CreateBunPackageItem("typescript", "5.3.3", isDev: true, isDirect: true)
|
||||
})
|
||||
};
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
try
|
||||
{
|
||||
var output = await CaptureTestConsoleAsync(async _ =>
|
||||
{
|
||||
await CommandHandlers.HandleBunResolveAsync(
|
||||
provider,
|
||||
imageReference: null,
|
||||
scanId: "scan-bun",
|
||||
format: "table",
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal("scan-bun", backend.LastBunPackagesScanId);
|
||||
Assert.Contains("scan-bun", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("lodash", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("express", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleBunResolveAsync_WritesJson()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
const string identifier = "bun-scan-json";
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||
{
|
||||
BunInventory = CreateBunInventory(
|
||||
identifier,
|
||||
new[]
|
||||
{
|
||||
CreateBunPackageItem("lodash", "4.17.21", isDev: false, isDirect: true)
|
||||
})
|
||||
};
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
try
|
||||
{
|
||||
var output = await CaptureTestConsoleAsync(async _ =>
|
||||
{
|
||||
await CommandHandlers.HandleBunResolveAsync(
|
||||
provider,
|
||||
imageReference: identifier,
|
||||
scanId: null,
|
||||
format: "json",
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Equal(identifier, backend.LastBunPackagesScanId);
|
||||
|
||||
using var document = JsonDocument.Parse(output.PlainBuffer);
|
||||
Assert.Equal(identifier, document.RootElement.GetProperty("scanId").GetString());
|
||||
|
||||
var packages = document.RootElement.GetProperty("packages");
|
||||
Assert.Single(packages.EnumerateArray());
|
||||
|
||||
var package = packages.EnumerateArray().First();
|
||||
Assert.Equal("lodash", package.GetProperty("name").GetString());
|
||||
Assert.Equal("4.17.21", package.GetProperty("version").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleBunResolveAsync_NotifiesWhenInventoryMissing()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
try
|
||||
{
|
||||
var output = await CaptureTestConsoleAsync(async _ =>
|
||||
{
|
||||
await CommandHandlers.HandleBunResolveAsync(
|
||||
provider,
|
||||
imageReference: null,
|
||||
scanId: "scan-missing-bun",
|
||||
format: "table",
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
});
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Contains("not available", output.Combined, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAdviseRunAsync_WritesOutputAndSetsExitCode()
|
||||
{
|
||||
@@ -4081,6 +4237,84 @@ spec:
|
||||
packages);
|
||||
}
|
||||
|
||||
private static void CreateBunWorkspace(string root)
|
||||
{
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "test-bun-app",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"lodash": "4.17.21",
|
||||
"express": "4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(root, "package.json"), packageJson);
|
||||
|
||||
var bunLock = """
|
||||
{
|
||||
"lockfileVersion": 0,
|
||||
"packages": {
|
||||
"lodash": ["lodash@4.17.21", { "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDE+k+xyz=" }],
|
||||
"express": ["express@4.18.2", { "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", "integrity": "sha512-expr+k+abc=" }],
|
||||
"typescript": ["typescript@5.3.3", { "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-ts+k+def=" }]
|
||||
},
|
||||
"workspaces": {}
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(root, "bun.lock"), bunLock);
|
||||
|
||||
var nodeModules = Path.Combine(root, "node_modules");
|
||||
Directory.CreateDirectory(nodeModules);
|
||||
|
||||
var lodashDir = Path.Combine(nodeModules, "lodash");
|
||||
Directory.CreateDirectory(lodashDir);
|
||||
File.WriteAllText(Path.Combine(lodashDir, "package.json"), """{"name":"lodash","version":"4.17.21"}""");
|
||||
|
||||
var expressDir = Path.Combine(nodeModules, "express");
|
||||
Directory.CreateDirectory(expressDir);
|
||||
File.WriteAllText(Path.Combine(expressDir, "package.json"), """{"name":"express","version":"4.18.2"}""");
|
||||
|
||||
var typescriptDir = Path.Combine(nodeModules, "typescript");
|
||||
Directory.CreateDirectory(typescriptDir);
|
||||
File.WriteAllText(Path.Combine(typescriptDir, "package.json"), """{"name":"typescript","version":"5.3.3"}""");
|
||||
}
|
||||
|
||||
private static BunPackageItem CreateBunPackageItem(
|
||||
string name,
|
||||
string? version = null,
|
||||
string? source = null,
|
||||
bool? isDev = null,
|
||||
bool? isDirect = null,
|
||||
IDictionary<string, string?>? metadata = null)
|
||||
{
|
||||
return new BunPackageItem(
|
||||
name,
|
||||
version,
|
||||
source ?? "registry",
|
||||
$"https://registry.npmjs.org/{name}/-/{name}-{version ?? "1.0.0"}.tgz",
|
||||
"sha512-abc123=",
|
||||
isDev,
|
||||
isDirect,
|
||||
IsPatched: null,
|
||||
CustomRegistry: null,
|
||||
metadata ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static BunPackageInventory CreateBunInventory(
|
||||
string scanId,
|
||||
IReadOnlyList<BunPackageItem> packages,
|
||||
string? imageDigest = null)
|
||||
{
|
||||
return new BunPackageInventory(
|
||||
scanId,
|
||||
imageDigest ?? "sha256:bun-inventory",
|
||||
DateTimeOffset.UtcNow,
|
||||
packages);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Base64(string path)
|
||||
{
|
||||
@@ -4165,6 +4399,9 @@ spec:
|
||||
public RubyPackageInventoryModel? RubyInventory { get; set; }
|
||||
public Exception? RubyInventoryException { get; set; }
|
||||
public string? LastRubyPackagesScanId { get; private set; }
|
||||
public BunPackageInventory? BunInventory { get; set; }
|
||||
public Exception? BunInventoryException { get; set; }
|
||||
public string? LastBunPackagesScanId { get; private set; }
|
||||
public List<(string ExportId, string DestinationPath, string? Algorithm, string? Digest)> ExportDownloads { get; } = new();
|
||||
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
|
||||
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
|
||||
@@ -4415,6 +4652,17 @@ spec:
|
||||
return Task.FromResult(RubyInventory);
|
||||
}
|
||||
|
||||
public Task<BunPackageInventory?> GetBunPackagesAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastBunPackagesScanId = scanId;
|
||||
if (BunInventoryException is not null)
|
||||
{
|
||||
throw BunInventoryException;
|
||||
}
|
||||
|
||||
return Task.FromResult(BunInventory);
|
||||
}
|
||||
|
||||
public Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
AdvisoryPlanRequests.Add((taskType, request));
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.Serialization;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
|
||||
namespace StellaOps.Excititor.Core.RiskFeed;
|
||||
|
||||
/// <summary>
|
||||
/// Risk-engine ready feed item containing VEX status, justification, and provenance
|
||||
/// WITHOUT derived severity (aggregation-only contract per AOC baseline).
|
||||
/// Aligns with docs/schemas/risk-scoring.schema.json.
|
||||
/// </summary>
|
||||
public sealed record RiskFeedItem
|
||||
{
|
||||
public RiskFeedItem(
|
||||
string advisoryKey,
|
||||
string artifact,
|
||||
VexClaimStatus status,
|
||||
VexJustification? justification,
|
||||
RiskFeedProvenance provenance,
|
||||
DateTimeOffset observedAt,
|
||||
ImmutableArray<RiskFeedObservationSource> sources)
|
||||
{
|
||||
AdvisoryKey = EnsureNotNullOrWhiteSpace(advisoryKey, nameof(advisoryKey));
|
||||
Artifact = EnsureNotNullOrWhiteSpace(artifact, nameof(artifact));
|
||||
Status = status;
|
||||
Justification = justification;
|
||||
Provenance = provenance ?? throw new ArgumentNullException(nameof(provenance));
|
||||
ObservedAt = observedAt.ToUniversalTime();
|
||||
Sources = sources.IsDefault ? ImmutableArray<RiskFeedObservationSource>.Empty : sources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory/CVE identifier (e.g., "CVE-2025-13579").
|
||||
/// </summary>
|
||||
public string AdvisoryKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL or product key of affected artifact.
|
||||
/// </summary>
|
||||
public string Artifact { get; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status (affected, not_affected, fixed, under_investigation).
|
||||
/// No derived severity - status is passed through unchanged.
|
||||
/// </summary>
|
||||
public VexClaimStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for not_affected status.
|
||||
/// </summary>
|
||||
public VexJustification? Justification { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance chain for auditability.
|
||||
/// </summary>
|
||||
public RiskFeedProvenance Provenance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When this observation was made (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset ObservedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Source observations contributing to this feed item.
|
||||
/// </summary>
|
||||
public ImmutableArray<RiskFeedObservationSource> Sources { get; }
|
||||
|
||||
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException($"{name} must be provided.", name);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provenance metadata for risk feed items - tracks origin and chain of custody.
|
||||
/// </summary>
|
||||
public sealed record RiskFeedProvenance
|
||||
{
|
||||
public RiskFeedProvenance(
|
||||
string tenantId,
|
||||
string linksetId,
|
||||
string contentHash,
|
||||
VexLinksetConfidence confidence,
|
||||
bool hasConflicts,
|
||||
DateTimeOffset generatedAt,
|
||||
string? attestationId = null)
|
||||
{
|
||||
TenantId = EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId)).ToLowerInvariant();
|
||||
LinksetId = EnsureNotNullOrWhiteSpace(linksetId, nameof(linksetId));
|
||||
ContentHash = EnsureNotNullOrWhiteSpace(contentHash, nameof(contentHash));
|
||||
Confidence = confidence;
|
||||
HasConflicts = hasConflicts;
|
||||
GeneratedAt = generatedAt.ToUniversalTime();
|
||||
AttestationId = string.IsNullOrWhiteSpace(attestationId) ? null : attestationId.Trim();
|
||||
}
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string LinksetId { get; }
|
||||
|
||||
public string ContentHash { get; }
|
||||
|
||||
public VexLinksetConfidence Confidence { get; }
|
||||
|
||||
public bool HasConflicts { get; }
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; }
|
||||
|
||||
public string? AttestationId { get; }
|
||||
|
||||
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException($"{name} must be provided.", name);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source observation reference for risk feed provenance.
|
||||
/// </summary>
|
||||
public sealed record RiskFeedObservationSource
|
||||
{
|
||||
public RiskFeedObservationSource(
|
||||
string observationId,
|
||||
string providerId,
|
||||
string status,
|
||||
string? justification = null,
|
||||
double? confidence = null)
|
||||
{
|
||||
ObservationId = EnsureNotNullOrWhiteSpace(observationId, nameof(observationId));
|
||||
ProviderId = EnsureNotNullOrWhiteSpace(providerId, nameof(providerId));
|
||||
Status = EnsureNotNullOrWhiteSpace(status, nameof(status));
|
||||
Justification = string.IsNullOrWhiteSpace(justification) ? null : justification.Trim();
|
||||
Confidence = confidence is null ? null : Math.Clamp(confidence.Value, 0.0, 1.0);
|
||||
}
|
||||
|
||||
public string ObservationId { get; }
|
||||
|
||||
public string ProviderId { get; }
|
||||
|
||||
public string Status { get; }
|
||||
|
||||
public string? Justification { get; }
|
||||
|
||||
public double? Confidence { get; }
|
||||
|
||||
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException($"{name} must be provided.", name);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to generate risk feed for specified artifacts.
|
||||
/// </summary>
|
||||
public sealed record RiskFeedRequest
|
||||
{
|
||||
public RiskFeedRequest(
|
||||
string tenantId,
|
||||
IEnumerable<string>? advisoryKeys = null,
|
||||
IEnumerable<string>? artifacts = null,
|
||||
DateTimeOffset? since = null,
|
||||
int limit = 1000)
|
||||
{
|
||||
TenantId = EnsureNotNullOrWhiteSpace(tenantId, nameof(tenantId)).ToLowerInvariant();
|
||||
AdvisoryKeys = NormalizeSet(advisoryKeys);
|
||||
Artifacts = NormalizeSet(artifacts);
|
||||
Since = since?.ToUniversalTime();
|
||||
Limit = Math.Clamp(limit, 1, 10000);
|
||||
}
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public ImmutableArray<string> AdvisoryKeys { get; }
|
||||
|
||||
public ImmutableArray<string> Artifacts { get; }
|
||||
|
||||
public DateTimeOffset? Since { get; }
|
||||
|
||||
public int Limit { get; }
|
||||
|
||||
private static string EnsureNotNullOrWhiteSpace(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException($"{name} must be provided.", name);
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeSet(IEnumerable<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var value in values)
|
||||
{
|
||||
var trimmed = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
if (trimmed is not null)
|
||||
{
|
||||
set.Add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return set.Count == 0 ? ImmutableArray<string>.Empty : set.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing risk feed items.
|
||||
/// </summary>
|
||||
public sealed record RiskFeedResponse
|
||||
{
|
||||
public RiskFeedResponse(
|
||||
IEnumerable<RiskFeedItem> items,
|
||||
DateTimeOffset generatedAt,
|
||||
string? nextPageToken = null)
|
||||
{
|
||||
Items = NormalizeItems(items);
|
||||
GeneratedAt = generatedAt.ToUniversalTime();
|
||||
NextPageToken = string.IsNullOrWhiteSpace(nextPageToken) ? null : nextPageToken.Trim();
|
||||
}
|
||||
|
||||
public ImmutableArray<RiskFeedItem> Items { get; }
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; }
|
||||
|
||||
public string? NextPageToken { get; }
|
||||
|
||||
private static ImmutableArray<RiskFeedItem> NormalizeItems(IEnumerable<RiskFeedItem>? items)
|
||||
{
|
||||
if (items is null)
|
||||
{
|
||||
return ImmutableArray<RiskFeedItem>.Empty;
|
||||
}
|
||||
|
||||
var list = items.Where(i => i is not null).ToList();
|
||||
return list.Count == 0 ? ImmutableArray<RiskFeedItem>.Empty : list.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event published when risk feed is generated.
|
||||
/// </summary>
|
||||
public sealed record RiskFeedGeneratedEvent
|
||||
{
|
||||
public const string EventType = "excititor.risk_feed.generated";
|
||||
|
||||
public RiskFeedGeneratedEvent(
|
||||
string tenantId,
|
||||
string feedId,
|
||||
int itemCount,
|
||||
DateTimeOffset generatedAt,
|
||||
string? correlationId = null)
|
||||
{
|
||||
Type = EventType;
|
||||
TenantId = tenantId.ToLowerInvariant();
|
||||
FeedId = feedId;
|
||||
ItemCount = itemCount;
|
||||
GeneratedAt = generatedAt.ToUniversalTime();
|
||||
CorrelationId = string.IsNullOrWhiteSpace(correlationId) ? null : correlationId.Trim();
|
||||
}
|
||||
|
||||
public string Type { get; }
|
||||
|
||||
public string TenantId { get; }
|
||||
|
||||
public string FeedId { get; }
|
||||
|
||||
public int ItemCount { get; }
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; }
|
||||
|
||||
public string? CorrelationId { get; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record BunPackagesResponse
|
||||
{
|
||||
[JsonPropertyName("scanId")]
|
||||
public string ScanId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("imageDigest")]
|
||||
public string ImageDigest { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
= DateTimeOffset.UtcNow;
|
||||
|
||||
[JsonPropertyName("packages")]
|
||||
public IReadOnlyList<BunPackageArtifact> Packages { get; init; }
|
||||
= Array.Empty<BunPackageArtifact>();
|
||||
}
|
||||
@@ -71,6 +71,12 @@ internal static class ScanEndpoints
|
||||
.Produces<RubyPackagesResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
scans.MapGet("/{scanId}/bun-packages", HandleBunPackagesAsync)
|
||||
.WithName("scanner.scans.bun-packages")
|
||||
.Produces<BunPackagesResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSubmitAsync(
|
||||
@@ -497,6 +503,63 @@ internal static class ScanEndpoints
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleBunPackagesAsync(
|
||||
string scanId,
|
||||
IScanCoordinator coordinator,
|
||||
IBunPackageInventoryStore inventoryStore,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(inventoryStore);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
var inventory = await inventoryStore.GetAsync(parsed.Value, cancellationToken).ConfigureAwait(false);
|
||||
if (inventory is null)
|
||||
{
|
||||
BunPackageInventory? fallback = null;
|
||||
if (!LooksLikeScanId(scanId))
|
||||
{
|
||||
var snapshot = await TryResolveSnapshotAsync(scanId, coordinator, cancellationToken).ConfigureAwait(false);
|
||||
if (snapshot is not null)
|
||||
{
|
||||
fallback = await inventoryStore.GetAsync(snapshot.ScanId.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback is null)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.NotFound,
|
||||
"Bun packages not found",
|
||||
StatusCodes.Status404NotFound,
|
||||
detail: "Bun package inventory is not available for the requested scan.");
|
||||
}
|
||||
|
||||
inventory = fallback;
|
||||
}
|
||||
|
||||
var response = new BunPackagesResponse
|
||||
{
|
||||
ScanId = inventory.ScanId,
|
||||
ImageDigest = inventory.ImageDigest,
|
||||
GeneratedAt = inventory.GeneratedAtUtc,
|
||||
Packages = inventory.Packages
|
||||
};
|
||||
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Surface;
|
||||
|
||||
internal static class BunPackageInventoryBuilder
|
||||
{
|
||||
private const string AnalyzerId = "bun";
|
||||
|
||||
public static IReadOnlyList<BunPackageArtifact> Build(LanguageAnalyzerResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var artifacts = new List<BunPackageArtifact>();
|
||||
foreach (var component in result.Components)
|
||||
{
|
||||
if (!component.AnalyzerId.Equals(AnalyzerId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(component.Type, "npm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = component.Metadata ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
var metadataCopy = new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var source = GetString(metadataCopy, "source");
|
||||
var resolved = GetString(metadataCopy, "resolved");
|
||||
var integrity = GetString(metadataCopy, "integrity");
|
||||
var lockfile = GetString(metadataCopy, "lockfile");
|
||||
var artifactLocator = GetString(metadataCopy, "artifact");
|
||||
|
||||
var isDev = TryParseBool(metadataCopy, "isDev");
|
||||
var isDirect = TryParseBool(metadataCopy, "isDirect");
|
||||
var isPatched = TryParseBool(metadataCopy, "isPatched");
|
||||
|
||||
var provenance = (source is not null || lockfile is not null || artifactLocator is not null)
|
||||
? new BunPackageProvenance(source, lockfile, artifactLocator ?? lockfile)
|
||||
: null;
|
||||
|
||||
artifacts.Add(new BunPackageArtifact(
|
||||
component.ComponentKey,
|
||||
component.Name,
|
||||
component.Version,
|
||||
source,
|
||||
resolved,
|
||||
integrity,
|
||||
isDev,
|
||||
isDirect,
|
||||
isPatched,
|
||||
provenance,
|
||||
metadataCopy));
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private static bool? TryParseBool(IReadOnlyDictionary<string, string?> metadata, string key)
|
||||
{
|
||||
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetString(IReadOnlyDictionary<string, string?> metadata, string key)
|
||||
{
|
||||
if (!metadata.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
private readonly ILogger<SurfaceManifestStageExecutor> _logger;
|
||||
private readonly ICryptoHash _hash;
|
||||
private readonly IRubyPackageInventoryStore _rubyPackageStore;
|
||||
private readonly IBunPackageInventoryStore _bunPackageStore;
|
||||
private readonly Determinism.DeterminismContext _determinism;
|
||||
private readonly IDsseEnvelopeSigner _dsseSigner;
|
||||
private readonly string _componentVersion;
|
||||
@@ -56,6 +57,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
ILogger<SurfaceManifestStageExecutor> logger,
|
||||
ICryptoHash hash,
|
||||
IRubyPackageInventoryStore rubyPackageStore,
|
||||
IBunPackageInventoryStore bunPackageStore,
|
||||
Determinism.DeterminismContext determinism,
|
||||
IDsseEnvelopeSigner dsseSigner)
|
||||
{
|
||||
@@ -67,6 +69,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_hash = hash ?? throw new ArgumentNullException(nameof(hash));
|
||||
_rubyPackageStore = rubyPackageStore ?? throw new ArgumentNullException(nameof(rubyPackageStore));
|
||||
_bunPackageStore = bunPackageStore ?? throw new ArgumentNullException(nameof(bunPackageStore));
|
||||
_determinism = determinism ?? throw new ArgumentNullException(nameof(determinism));
|
||||
_dsseSigner = dsseSigner ?? throw new ArgumentNullException(nameof(dsseSigner));
|
||||
_componentVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
|
||||
@@ -80,6 +83,7 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
|
||||
var payloads = CollectPayloads(context);
|
||||
await PersistRubyPackagesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
await PersistBunPackagesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var determinismPayloads = BuildDeterminismPayloads(context, payloads, out var merkleRoot);
|
||||
if (determinismPayloads is not null && determinismPayloads.Count > 0)
|
||||
@@ -491,6 +495,33 @@ internal sealed class SurfaceManifestStageExecutor : IScanStageExecutor
|
||||
await _rubyPackageStore.StoreAsync(inventory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task PersistBunPackagesAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!context.Analysis.TryGet<ReadOnlyDictionary<string, LanguageAnalyzerResult>>(ScanAnalysisKeys.LanguageAnalyzerResults, out var results))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!results.TryGetValue("bun", out var bunResult) || bunResult is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var packages = BunPackageInventoryBuilder.Build(bunResult);
|
||||
if (packages.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var inventory = new BunPackageInventory(
|
||||
context.ScanId,
|
||||
ResolveImageDigest(context),
|
||||
context.TimeProvider.GetUtcNow(),
|
||||
packages);
|
||||
|
||||
await _bunPackageStore.StoreAsync(inventory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task PersistPayloadsToSurfaceCacheAsync(
|
||||
ScanJobContext context,
|
||||
string tenant,
|
||||
|
||||
@@ -106,6 +106,7 @@ builder.Services.AddSingleton<IDsseEnvelopeSigner, HmacDsseEnvelopeSigner>();
|
||||
else
|
||||
{
|
||||
builder.Services.TryAddSingleton<IRubyPackageInventoryStore, NullRubyPackageInventoryStore>();
|
||||
builder.Services.TryAddSingleton<IBunPackageInventoryStore, NullBunPackageInventoryStore>();
|
||||
}
|
||||
|
||||
builder.Services.TryAddSingleton<IScanJobSource, NullScanJobSource>();
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a declared Java dependency with full GAV coordinates, scope, and exclusions.
|
||||
/// Used across both Maven and Gradle parsers.
|
||||
/// </summary>
|
||||
internal sealed record JavaDependencyDeclaration
|
||||
{
|
||||
public required string GroupId { get; init; }
|
||||
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version string. May contain property placeholders (e.g., "${spring.version}") that need resolution.
|
||||
/// </summary>
|
||||
public required string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Dependency scope: compile, test, provided, runtime, system, import.
|
||||
/// </summary>
|
||||
public string? Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Classifier for the artifact (e.g., "sources", "javadoc", "jdk11").
|
||||
/// </summary>
|
||||
public string? Classifier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Packaging type (e.g., "jar", "pom", "war").
|
||||
/// </summary>
|
||||
public string? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is an optional dependency.
|
||||
/// </summary>
|
||||
public bool Optional { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Exclusions for transitive dependencies.
|
||||
/// </summary>
|
||||
public ImmutableArray<JavaExclusion> Exclusions { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Source of this declaration (e.g., "pom.xml", "build.gradle", "build.gradle.kts").
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File path locator relative to the project root.
|
||||
/// </summary>
|
||||
public string? Locator { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates how the version was resolved.
|
||||
/// </summary>
|
||||
public JavaVersionSource VersionSource { get; init; } = JavaVersionSource.Direct;
|
||||
|
||||
/// <summary>
|
||||
/// Original property name if version came from a property (e.g., "spring.version").
|
||||
/// </summary>
|
||||
public string? VersionProperty { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether version is fully resolved (no remaining ${...} placeholders).
|
||||
/// </summary>
|
||||
public bool IsVersionResolved => Version is not null &&
|
||||
!Version.Contains("${", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate as "groupId:artifactId:version".
|
||||
/// </summary>
|
||||
public string Gav => Version is null
|
||||
? $"{GroupId}:{ArtifactId}"
|
||||
: $"{GroupId}:{ArtifactId}:{Version}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the unique key for deduplication.
|
||||
/// </summary>
|
||||
public string Key => BuildKey(GroupId, ArtifactId, Version ?? "*");
|
||||
|
||||
private static string BuildKey(string groupId, string artifactId, string version)
|
||||
=> $"{groupId}:{artifactId}:{version}".ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an exclusion for transitive dependencies.
|
||||
/// </summary>
|
||||
internal sealed record JavaExclusion(string GroupId, string ArtifactId);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates the source of version resolution.
|
||||
/// </summary>
|
||||
internal enum JavaVersionSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Version declared directly in the dependency.
|
||||
/// </summary>
|
||||
Direct,
|
||||
|
||||
/// <summary>
|
||||
/// Version inherited from parent POM.
|
||||
/// </summary>
|
||||
Parent,
|
||||
|
||||
/// <summary>
|
||||
/// Version resolved from dependencyManagement in current POM.
|
||||
/// </summary>
|
||||
DependencyManagement,
|
||||
|
||||
/// <summary>
|
||||
/// Version resolved from an imported BOM.
|
||||
/// </summary>
|
||||
Bom,
|
||||
|
||||
/// <summary>
|
||||
/// Version resolved from a property placeholder.
|
||||
/// </summary>
|
||||
Property,
|
||||
|
||||
/// <summary>
|
||||
/// Version resolved from Gradle version catalog.
|
||||
/// </summary>
|
||||
VersionCatalog,
|
||||
|
||||
/// <summary>
|
||||
/// Version could not be resolved.
|
||||
/// </summary>
|
||||
Unresolved
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps dependency scopes to risk levels for security analysis.
|
||||
/// </summary>
|
||||
internal static class JavaScopeClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a Maven/Gradle scope to a risk level.
|
||||
/// </summary>
|
||||
public static string GetRiskLevel(string? scope) => scope?.ToLowerInvariant() switch
|
||||
{
|
||||
null or "" or "compile" or "implementation" or "api" => "production",
|
||||
"runtime" or "runtimeOnly" => "production",
|
||||
"test" or "testImplementation" or "testCompileOnly" or "testRuntimeOnly" => "development",
|
||||
"provided" or "compileOnly" => "provided",
|
||||
"system" => "system",
|
||||
_ => "production" // Default to production for unknown scopes
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the scope indicates a direct (not transitive) dependency.
|
||||
/// </summary>
|
||||
public static bool IsDirect(string? scope) => scope?.ToLowerInvariant() switch
|
||||
{
|
||||
"compile" or "implementation" or "api" or "test" or "testImplementation" => true,
|
||||
"runtime" or "runtimeOnly" or "testRuntimeOnly" => false,
|
||||
"provided" or "compileOnly" or "testCompileOnly" => true,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
/// <summary>
|
||||
/// Represents unified project metadata from Maven POM or Gradle build files.
|
||||
/// </summary>
|
||||
internal sealed record JavaProjectMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Project group ID (Maven groupId or Gradle group).
|
||||
/// </summary>
|
||||
public string? GroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Project artifact ID (Maven artifactId or Gradle name).
|
||||
/// </summary>
|
||||
public string? ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Project version.
|
||||
/// </summary>
|
||||
public string? Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Packaging type (jar, war, pom, etc.).
|
||||
/// </summary>
|
||||
public string? Packaging { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent project reference (Maven parent POM or Gradle parent project).
|
||||
/// </summary>
|
||||
public JavaParentReference? Parent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Project properties (Maven properties or Gradle ext properties).
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Properties { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Declared licenses for the project.
|
||||
/// </summary>
|
||||
public ImmutableArray<JavaLicenseInfo> Licenses { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Dependencies declared in this project.
|
||||
/// </summary>
|
||||
public ImmutableArray<JavaDependencyDeclaration> Dependencies { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Dependency management entries (Maven dependencyManagement or Gradle platform).
|
||||
/// </summary>
|
||||
public ImmutableArray<JavaDependencyDeclaration> DependencyManagement { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Source file path relative to the project root.
|
||||
/// </summary>
|
||||
public string? SourcePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build system type.
|
||||
/// </summary>
|
||||
public JavaBuildSystem BuildSystem { get; init; } = JavaBuildSystem.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate of this project.
|
||||
/// </summary>
|
||||
public string? Gav => GroupId is not null && ArtifactId is not null
|
||||
? Version is not null
|
||||
? $"{GroupId}:{ArtifactId}:{Version}"
|
||||
: $"{GroupId}:{ArtifactId}"
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective group ID, falling back to parent if not set.
|
||||
/// </summary>
|
||||
public string? GetEffectiveGroupId()
|
||||
=> GroupId ?? Parent?.GroupId;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective version, falling back to parent if not set.
|
||||
/// </summary>
|
||||
public string? GetEffectiveVersion()
|
||||
=> Version ?? Parent?.Version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a reference to a parent project.
|
||||
/// </summary>
|
||||
internal sealed record JavaParentReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Parent group ID.
|
||||
/// </summary>
|
||||
public required string GroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parent version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to parent POM (Maven only).
|
||||
/// </summary>
|
||||
public string? RelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the parent was successfully resolved.
|
||||
/// </summary>
|
||||
public bool IsResolved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The resolved parent metadata (null if unresolved).
|
||||
/// </summary>
|
||||
public JavaProjectMetadata? ResolvedParent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate of the parent.
|
||||
/// </summary>
|
||||
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents license information extracted from project metadata.
|
||||
/// </summary>
|
||||
internal sealed record JavaLicenseInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// License name as declared in the project file.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// License URL if available.
|
||||
/// </summary>
|
||||
public string? Url { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// License distribution type (repo, manual, etc.).
|
||||
/// </summary>
|
||||
public string? Distribution { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comments about the license.
|
||||
/// </summary>
|
||||
public string? Comments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Normalized SPDX identifier (null if not normalized).
|
||||
/// </summary>
|
||||
public string? SpdxId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level of the SPDX normalization.
|
||||
/// </summary>
|
||||
public SpdxConfidence SpdxConfidence { get; init; } = SpdxConfidence.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for SPDX license normalization.
|
||||
/// </summary>
|
||||
internal enum SpdxConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// No SPDX mapping available.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Low confidence mapping (partial match).
|
||||
/// </summary>
|
||||
Low,
|
||||
|
||||
/// <summary>
|
||||
/// Medium confidence mapping (common name or URL match).
|
||||
/// </summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>
|
||||
/// High confidence mapping (exact name or official URL).
|
||||
/// </summary>
|
||||
High
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build system type.
|
||||
/// </summary>
|
||||
internal enum JavaBuildSystem
|
||||
{
|
||||
Unknown,
|
||||
Maven,
|
||||
GradleGroovy,
|
||||
GradleKotlin,
|
||||
Ant,
|
||||
Bazel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a BOM (Bill of Materials) import.
|
||||
/// </summary>
|
||||
internal sealed record JavaBomImport
|
||||
{
|
||||
/// <summary>
|
||||
/// BOM group ID.
|
||||
/// </summary>
|
||||
public required string GroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BOM artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BOM version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the BOM was successfully resolved.
|
||||
/// </summary>
|
||||
public bool IsResolved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolved dependency management entries from the BOM.
|
||||
/// </summary>
|
||||
public ImmutableArray<JavaDependencyDeclaration> ManagedDependencies { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate of the BOM.
|
||||
/// </summary>
|
||||
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Conflicts;
|
||||
|
||||
/// <summary>
|
||||
/// Detects version conflicts where the same artifact appears with multiple versions.
|
||||
/// </summary>
|
||||
internal static class VersionConflictDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes dependencies for version conflicts.
|
||||
/// </summary>
|
||||
public static VersionConflictAnalysis Analyze(IEnumerable<JavaDependencyDeclaration> dependencies)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dependencies);
|
||||
|
||||
var dependencyList = dependencies.ToList();
|
||||
if (dependencyList.Count == 0)
|
||||
{
|
||||
return VersionConflictAnalysis.Empty;
|
||||
}
|
||||
|
||||
// Group by groupId:artifactId
|
||||
var groups = dependencyList
|
||||
.Where(d => !string.IsNullOrWhiteSpace(d.Version))
|
||||
.GroupBy(d => $"{d.GroupId}:{d.ArtifactId}".ToLowerInvariant())
|
||||
.Where(g => g.Select(d => d.Version).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
|
||||
.ToList();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
return VersionConflictAnalysis.Empty;
|
||||
}
|
||||
|
||||
var conflicts = new List<VersionConflict>();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var versions = group
|
||||
.Select(d => new VersionOccurrence(
|
||||
d.Version!,
|
||||
d.Source,
|
||||
d.Locator,
|
||||
d.Scope ?? "compile"))
|
||||
.OrderBy(v => v.Version, VersionComparer.Instance)
|
||||
.ToImmutableArray();
|
||||
|
||||
var parts = group.Key.Split(':');
|
||||
var groupId = parts[0];
|
||||
var artifactId = parts.Length > 1 ? parts[1] : string.Empty;
|
||||
|
||||
// Determine severity based on version distance
|
||||
var severity = CalculateSeverity(versions);
|
||||
|
||||
conflicts.Add(new VersionConflict(
|
||||
groupId,
|
||||
artifactId,
|
||||
versions,
|
||||
severity));
|
||||
}
|
||||
|
||||
return new VersionConflictAnalysis(
|
||||
[.. conflicts.OrderBy(c => c.GroupId).ThenBy(c => c.ArtifactId)],
|
||||
conflicts.Count,
|
||||
conflicts.Max(c => c.Severity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes artifacts (from JARs) for version conflicts.
|
||||
/// </summary>
|
||||
public static VersionConflictAnalysis AnalyzeArtifacts(
|
||||
IEnumerable<(string GroupId, string ArtifactId, string Version, string Source)> artifacts)
|
||||
{
|
||||
var dependencies = artifacts
|
||||
.Select(a => new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = a.GroupId,
|
||||
ArtifactId = a.ArtifactId,
|
||||
Version = a.Version,
|
||||
Source = a.Source,
|
||||
Locator = a.Source
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Analyze(dependencies);
|
||||
}
|
||||
|
||||
private static ConflictSeverity CalculateSeverity(ImmutableArray<VersionOccurrence> versions)
|
||||
{
|
||||
var versionStrings = versions.Select(v => v.Version).Distinct().ToList();
|
||||
|
||||
if (versionStrings.Count == 1)
|
||||
{
|
||||
return ConflictSeverity.None;
|
||||
}
|
||||
|
||||
// Try to parse as semantic versions
|
||||
var semvers = versionStrings
|
||||
.Select(TryParseSemanticVersion)
|
||||
.Where(v => v is not null)
|
||||
.Cast<SemanticVersion>()
|
||||
.ToList();
|
||||
|
||||
if (semvers.Count < 2)
|
||||
{
|
||||
// Can't determine severity without parseable versions
|
||||
return ConflictSeverity.Medium;
|
||||
}
|
||||
|
||||
// Check for major version differences (high severity)
|
||||
var majorVersions = semvers.Select(v => v.Major).Distinct().ToList();
|
||||
if (majorVersions.Count > 1)
|
||||
{
|
||||
return ConflictSeverity.High;
|
||||
}
|
||||
|
||||
// Check for minor version differences (medium severity)
|
||||
var minorVersions = semvers.Select(v => v.Minor).Distinct().ToList();
|
||||
if (minorVersions.Count > 1)
|
||||
{
|
||||
return ConflictSeverity.Medium;
|
||||
}
|
||||
|
||||
// Only patch version differences (low severity)
|
||||
return ConflictSeverity.Low;
|
||||
}
|
||||
|
||||
private static SemanticVersion? TryParseSemanticVersion(string version)
|
||||
{
|
||||
// Handle versions like "1.2.3", "1.2.3-SNAPSHOT", "1.2.3.Final"
|
||||
var cleanVersion = version
|
||||
.Split('-')[0] // Remove suffix like -SNAPSHOT
|
||||
.Split('.', 4); // Split into parts
|
||||
|
||||
if (cleanVersion.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(cleanVersion[0], out var major))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var minor = cleanVersion.Length > 1 && int.TryParse(cleanVersion[1], out var m) ? m : 0;
|
||||
var patch = cleanVersion.Length > 2 && int.TryParse(cleanVersion[2], out var p) ? p : 0;
|
||||
|
||||
return new SemanticVersion(major, minor, patch);
|
||||
}
|
||||
|
||||
private sealed record SemanticVersion(int Major, int Minor, int Patch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of version conflict analysis.
|
||||
/// </summary>
|
||||
internal sealed record VersionConflictAnalysis(
|
||||
ImmutableArray<VersionConflict> Conflicts,
|
||||
int TotalConflicts,
|
||||
ConflictSeverity MaxSeverity)
|
||||
{
|
||||
public static readonly VersionConflictAnalysis Empty = new([], 0, ConflictSeverity.None);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any conflicts were found.
|
||||
/// </summary>
|
||||
public bool HasConflicts => TotalConflicts > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets conflicts for a specific artifact.
|
||||
/// </summary>
|
||||
public VersionConflict? GetConflict(string groupId, string artifactId)
|
||||
=> Conflicts.FirstOrDefault(c =>
|
||||
string.Equals(c.GroupId, groupId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.ArtifactId, artifactId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a version conflict for a single artifact.
|
||||
/// </summary>
|
||||
internal sealed record VersionConflict(
|
||||
string GroupId,
|
||||
string ArtifactId,
|
||||
ImmutableArray<VersionOccurrence> Versions,
|
||||
ConflictSeverity Severity)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact coordinate (groupId:artifactId).
|
||||
/// </summary>
|
||||
public string Coordinate => $"{GroupId}:{ArtifactId}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets all unique version strings.
|
||||
/// </summary>
|
||||
public IEnumerable<string> UniqueVersions
|
||||
=> Versions.Select(v => v.Version).Distinct();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the versions as a comma-separated string.
|
||||
/// </summary>
|
||||
public string VersionsString
|
||||
=> string.Join(",", UniqueVersions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single occurrence of a version.
|
||||
/// </summary>
|
||||
internal sealed record VersionOccurrence(
|
||||
string Version,
|
||||
string? Source,
|
||||
string? Locator,
|
||||
string Scope);
|
||||
|
||||
/// <summary>
|
||||
/// Severity level of a version conflict.
|
||||
/// </summary>
|
||||
internal enum ConflictSeverity
|
||||
{
|
||||
/// <summary>
|
||||
/// No conflict.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Only patch version differences (likely compatible).
|
||||
/// </summary>
|
||||
Low = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Minor version differences (may have API changes).
|
||||
/// </summary>
|
||||
Medium = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Major version differences (likely incompatible).
|
||||
/// </summary>
|
||||
High = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Comparer for semantic version strings.
|
||||
/// </summary>
|
||||
internal sealed class VersionComparer : IComparer<string>
|
||||
{
|
||||
public static readonly VersionComparer Instance = new();
|
||||
|
||||
public int Compare(string? x, string? y)
|
||||
{
|
||||
if (x is null && y is null) return 0;
|
||||
if (x is null) return -1;
|
||||
if (y is null) return 1;
|
||||
|
||||
var xParts = x.Split(['.', '-'], StringSplitOptions.RemoveEmptyEntries);
|
||||
var yParts = y.Split(['.', '-'], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var maxParts = Math.Max(xParts.Length, yParts.Length);
|
||||
|
||||
for (int i = 0; i < maxParts; i++)
|
||||
{
|
||||
var xPart = i < xParts.Length ? xParts[i] : "0";
|
||||
var yPart = i < yParts.Length ? yParts[i] : "0";
|
||||
|
||||
// Try numeric comparison first
|
||||
if (int.TryParse(xPart, out var xNum) && int.TryParse(yPart, out var yNum))
|
||||
{
|
||||
var numCompare = xNum.CompareTo(yNum);
|
||||
if (numCompare != 0) return numCompare;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to string comparison
|
||||
var strCompare = string.Compare(xPart, yPart, StringComparison.OrdinalIgnoreCase);
|
||||
if (strCompare != 0) return strCompare;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers Java/JVM build files in a directory tree.
|
||||
/// </summary>
|
||||
internal static class JavaBuildFileDiscovery
|
||||
{
|
||||
private static readonly string[] MavenFiles = ["pom.xml"];
|
||||
private static readonly string[] GradleGroovyFiles = ["build.gradle", "settings.gradle"];
|
||||
private static readonly string[] GradleKotlinFiles = ["build.gradle.kts", "settings.gradle.kts"];
|
||||
private static readonly string[] GradleLockFiles = ["gradle.lockfile"];
|
||||
private static readonly string[] GradlePropertiesFiles = ["gradle.properties"];
|
||||
private static readonly string[] GradleVersionCatalogFiles = ["libs.versions.toml", "gradle/libs.versions.toml"];
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all Java build files in the given directory tree.
|
||||
/// </summary>
|
||||
public static JavaBuildFiles Discover(string rootPath, int maxDepth = 10)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
|
||||
if (!Directory.Exists(rootPath))
|
||||
{
|
||||
return JavaBuildFiles.Empty;
|
||||
}
|
||||
|
||||
var maven = new List<DiscoveredBuildFile>();
|
||||
var gradleGroovy = new List<DiscoveredBuildFile>();
|
||||
var gradleKotlin = new List<DiscoveredBuildFile>();
|
||||
var gradleLock = new List<DiscoveredBuildFile>();
|
||||
var gradleProperties = new List<DiscoveredBuildFile>();
|
||||
var versionCatalogs = new List<DiscoveredBuildFile>();
|
||||
|
||||
DiscoverRecursive(rootPath, rootPath, 0, maxDepth,
|
||||
maven, gradleGroovy, gradleKotlin, gradleLock, gradleProperties, versionCatalogs);
|
||||
|
||||
return new JavaBuildFiles(
|
||||
[.. maven.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||
[.. gradleGroovy.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||
[.. gradleKotlin.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||
[.. gradleLock.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||
[.. gradleProperties.OrderBy(f => f.RelativePath, StringComparer.Ordinal)],
|
||||
[.. versionCatalogs.OrderBy(f => f.RelativePath, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
private static void DiscoverRecursive(
|
||||
string currentPath,
|
||||
string rootPath,
|
||||
int currentDepth,
|
||||
int maxDepth,
|
||||
List<DiscoveredBuildFile> maven,
|
||||
List<DiscoveredBuildFile> gradleGroovy,
|
||||
List<DiscoveredBuildFile> gradleKotlin,
|
||||
List<DiscoveredBuildFile> gradleLock,
|
||||
List<DiscoveredBuildFile> gradleProperties,
|
||||
List<DiscoveredBuildFile> versionCatalogs)
|
||||
{
|
||||
if (currentDepth > maxDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Check for files in current directory
|
||||
foreach (var file in MavenFiles)
|
||||
{
|
||||
var path = Path.Combine(currentPath, file);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
maven.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.Maven));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in GradleGroovyFiles)
|
||||
{
|
||||
var path = Path.Combine(currentPath, file);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
gradleGroovy.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in GradleKotlinFiles)
|
||||
{
|
||||
var path = Path.Combine(currentPath, file);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
gradleKotlin.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleKotlin));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in GradleLockFiles)
|
||||
{
|
||||
var path = Path.Combine(currentPath, file);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
gradleLock.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in GradlePropertiesFiles)
|
||||
{
|
||||
var path = Path.Combine(currentPath, file);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
gradleProperties.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for version catalog files (can be in root or gradle/ subdirectory)
|
||||
foreach (var file in GradleVersionCatalogFiles)
|
||||
{
|
||||
var path = Path.Combine(currentPath, file);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
versionCatalogs.Add(CreateDiscoveredFile(path, rootPath, JavaBuildSystem.GradleGroovy));
|
||||
}
|
||||
}
|
||||
|
||||
// Also check gradle/dependency-locks directory for lock files
|
||||
var dependencyLocksDir = Path.Combine(currentPath, "gradle", "dependency-locks");
|
||||
if (Directory.Exists(dependencyLocksDir))
|
||||
{
|
||||
foreach (var lockFile in Directory.EnumerateFiles(dependencyLocksDir, "*.lockfile", SearchOption.AllDirectories))
|
||||
{
|
||||
gradleLock.Add(CreateDiscoveredFile(lockFile, rootPath, JavaBuildSystem.GradleGroovy));
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
foreach (var subDir in Directory.EnumerateDirectories(currentPath))
|
||||
{
|
||||
var dirName = Path.GetFileName(subDir);
|
||||
|
||||
// Skip common non-project directories
|
||||
if (ShouldSkipDirectory(dirName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DiscoverRecursive(subDir, rootPath, currentDepth + 1, maxDepth,
|
||||
maven, gradleGroovy, gradleKotlin, gradleLock, gradleProperties, versionCatalogs);
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip directories we can't access
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory was deleted while scanning
|
||||
}
|
||||
}
|
||||
|
||||
private static DiscoveredBuildFile CreateDiscoveredFile(string absolutePath, string rootPath, JavaBuildSystem buildSystem)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootPath, absolutePath).Replace('\\', '/');
|
||||
var projectDirectory = Path.GetDirectoryName(relativePath) ?? ".";
|
||||
if (string.IsNullOrEmpty(projectDirectory))
|
||||
{
|
||||
projectDirectory = ".";
|
||||
}
|
||||
|
||||
return new DiscoveredBuildFile(
|
||||
absolutePath,
|
||||
relativePath,
|
||||
projectDirectory,
|
||||
Path.GetFileName(absolutePath),
|
||||
buildSystem);
|
||||
}
|
||||
|
||||
private static bool ShouldSkipDirectory(string dirName)
|
||||
{
|
||||
return dirName switch
|
||||
{
|
||||
"node_modules" or ".git" or ".svn" or ".hg" => true,
|
||||
"target" or "build" or "out" or "bin" or "obj" => true,
|
||||
".gradle" or ".idea" or ".vscode" or ".settings" => true,
|
||||
"__pycache__" or "vendor" or "dist" => true,
|
||||
_ when dirName.StartsWith('.') => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered build file.
|
||||
/// </summary>
|
||||
internal sealed record DiscoveredBuildFile(
|
||||
string AbsolutePath,
|
||||
string RelativePath,
|
||||
string ProjectDirectory,
|
||||
string FileName,
|
||||
JavaBuildSystem BuildSystem);
|
||||
|
||||
/// <summary>
|
||||
/// Collection of discovered Java build files.
|
||||
/// </summary>
|
||||
internal sealed record JavaBuildFiles(
|
||||
ImmutableArray<DiscoveredBuildFile> MavenPoms,
|
||||
ImmutableArray<DiscoveredBuildFile> GradleGroovyFiles,
|
||||
ImmutableArray<DiscoveredBuildFile> GradleKotlinFiles,
|
||||
ImmutableArray<DiscoveredBuildFile> GradleLockFiles,
|
||||
ImmutableArray<DiscoveredBuildFile> GradlePropertiesFiles,
|
||||
ImmutableArray<DiscoveredBuildFile> VersionCatalogFiles)
|
||||
{
|
||||
public static readonly JavaBuildFiles Empty = new([], [], [], [], [], []);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if any build files were found.
|
||||
/// </summary>
|
||||
public bool HasAny =>
|
||||
MavenPoms.Length > 0 ||
|
||||
GradleGroovyFiles.Length > 0 ||
|
||||
GradleKotlinFiles.Length > 0 ||
|
||||
GradleLockFiles.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the project uses Maven.
|
||||
/// </summary>
|
||||
public bool UsesMaven => MavenPoms.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the project uses Gradle.
|
||||
/// </summary>
|
||||
public bool UsesGradle =>
|
||||
GradleGroovyFiles.Length > 0 ||
|
||||
GradleKotlinFiles.Length > 0 ||
|
||||
GradleLockFiles.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if Gradle lockfiles are present (preferred source).
|
||||
/// </summary>
|
||||
public bool HasGradleLockFiles => GradleLockFiles.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a version catalog is present.
|
||||
/// </summary>
|
||||
public bool HasVersionCatalog => VersionCatalogFiles.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Determines the primary build system.
|
||||
/// </summary>
|
||||
public JavaBuildSystem PrimaryBuildSystem
|
||||
{
|
||||
get
|
||||
{
|
||||
// Gradle lockfiles take precedence
|
||||
if (HasGradleLockFiles)
|
||||
{
|
||||
return JavaBuildSystem.GradleGroovy;
|
||||
}
|
||||
|
||||
// Then Gradle build files
|
||||
if (GradleKotlinFiles.Length > 0)
|
||||
{
|
||||
return JavaBuildSystem.GradleKotlin;
|
||||
}
|
||||
|
||||
if (GradleGroovyFiles.Length > 0)
|
||||
{
|
||||
return JavaBuildSystem.GradleGroovy;
|
||||
}
|
||||
|
||||
// Fall back to Maven
|
||||
if (UsesMaven)
|
||||
{
|
||||
return JavaBuildSystem.Maven;
|
||||
}
|
||||
|
||||
return JavaBuildSystem.Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all discovered projects grouped by directory.
|
||||
/// </summary>
|
||||
public IEnumerable<JavaProjectFiles> GetProjectsByDirectory()
|
||||
{
|
||||
var allFiles = MavenPoms
|
||||
.Concat(GradleGroovyFiles)
|
||||
.Concat(GradleKotlinFiles)
|
||||
.Concat(GradleLockFiles)
|
||||
.Concat(GradlePropertiesFiles)
|
||||
.Concat(VersionCatalogFiles);
|
||||
|
||||
return allFiles
|
||||
.GroupBy(f => f.ProjectDirectory, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new JavaProjectFiles(
|
||||
g.Key,
|
||||
g.FirstOrDefault(f => f.FileName == "pom.xml"),
|
||||
g.FirstOrDefault(f => f.FileName == "build.gradle"),
|
||||
g.FirstOrDefault(f => f.FileName == "build.gradle.kts"),
|
||||
g.FirstOrDefault(f => f.FileName == "gradle.lockfile"),
|
||||
g.FirstOrDefault(f => f.FileName == "gradle.properties"),
|
||||
g.FirstOrDefault(f => f.FileName == "libs.versions.toml")))
|
||||
.OrderBy(p => p.Directory, StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the build files for a single project directory.
|
||||
/// </summary>
|
||||
internal sealed record JavaProjectFiles(
|
||||
string Directory,
|
||||
DiscoveredBuildFile? PomXml,
|
||||
DiscoveredBuildFile? BuildGradle,
|
||||
DiscoveredBuildFile? BuildGradleKts,
|
||||
DiscoveredBuildFile? GradleLockfile,
|
||||
DiscoveredBuildFile? GradleProperties,
|
||||
DiscoveredBuildFile? VersionCatalog)
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines the primary build system for this project.
|
||||
/// </summary>
|
||||
public JavaBuildSystem PrimaryBuildSystem
|
||||
{
|
||||
get
|
||||
{
|
||||
if (GradleLockfile is not null || BuildGradle is not null)
|
||||
{
|
||||
return JavaBuildSystem.GradleGroovy;
|
||||
}
|
||||
|
||||
if (BuildGradleKts is not null)
|
||||
{
|
||||
return JavaBuildSystem.GradleKotlin;
|
||||
}
|
||||
|
||||
if (PomXml is not null)
|
||||
{
|
||||
return JavaBuildSystem.Maven;
|
||||
}
|
||||
|
||||
return JavaBuildSystem.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Gradle Groovy DSL build files (build.gradle).
|
||||
/// Uses regex-based parsing to extract dependency declarations from common patterns.
|
||||
/// </summary>
|
||||
internal static partial class GradleGroovyParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Gradle configuration names that indicate dependency declarations.
|
||||
/// </summary>
|
||||
private static readonly string[] DependencyConfigurations =
|
||||
[
|
||||
"implementation", "api", "compileOnly", "runtimeOnly",
|
||||
"testImplementation", "testCompileOnly", "testRuntimeOnly",
|
||||
"annotationProcessor", "kapt", "ksp",
|
||||
"compile", "runtime", "testCompile", "testRuntime", // Legacy
|
||||
"providedCompile", "providedRuntime" // Legacy WAR plugin
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Parses a build.gradle file asynchronously.
|
||||
/// </summary>
|
||||
public static async Task<GradleBuildFile> ParseAsync(
|
||||
string path,
|
||||
GradleProperties? properties = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return GradleBuildFile.Empty;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, path, properties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses build.gradle content.
|
||||
/// </summary>
|
||||
public static GradleBuildFile Parse(string content, string sourcePath, GradleProperties? properties = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return GradleBuildFile.Empty;
|
||||
}
|
||||
|
||||
var dependencies = new List<JavaDependencyDeclaration>();
|
||||
var plugins = new List<GradlePlugin>();
|
||||
var unresolvedDependencies = new List<string>();
|
||||
|
||||
// Extract group and version from build file
|
||||
var group = ExtractProperty(content, "group");
|
||||
var version = ExtractProperty(content, "version");
|
||||
|
||||
// Parse plugins block
|
||||
ParsePlugins(content, plugins);
|
||||
|
||||
// Parse dependencies block
|
||||
ParseDependencies(content, sourcePath, properties, dependencies, unresolvedDependencies);
|
||||
|
||||
// Parse platform/BOM declarations
|
||||
ParsePlatformDependencies(content, sourcePath, dependencies);
|
||||
|
||||
return new GradleBuildFile(
|
||||
sourcePath,
|
||||
JavaBuildSystem.GradleGroovy,
|
||||
group,
|
||||
version,
|
||||
[.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)],
|
||||
[.. plugins.OrderBy(p => p.Id, StringComparer.Ordinal)],
|
||||
[.. unresolvedDependencies.Distinct().OrderBy(u => u, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
private static string? ExtractProperty(string content, string propertyName)
|
||||
{
|
||||
// Match: group = 'com.example' or group 'com.example'
|
||||
var pattern = $@"(?:^|\s){propertyName}\s*[=]?\s*['""]([^'""]+)['""]";
|
||||
var match = Regex.Match(content, pattern, RegexOptions.Multiline);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private static void ParsePlugins(string content, List<GradlePlugin> plugins)
|
||||
{
|
||||
// Match plugins { ... } block
|
||||
var pluginsBlockMatch = PluginsBlockPattern().Match(content);
|
||||
if (!pluginsBlockMatch.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var block = pluginsBlockMatch.Groups[1].Value;
|
||||
|
||||
// Match id 'plugin-id' version 'x.y.z'
|
||||
foreach (Match match in PluginPattern().Matches(block))
|
||||
{
|
||||
var id = match.Groups[1].Value;
|
||||
var version = match.Groups.Count > 2 ? match.Groups[2].Value : null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
plugins.Add(new GradlePlugin(id, version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseDependencies(
|
||||
string content,
|
||||
string sourcePath,
|
||||
GradleProperties? properties,
|
||||
List<JavaDependencyDeclaration> dependencies,
|
||||
List<string> unresolved)
|
||||
{
|
||||
// Match dependencies { ... } block
|
||||
var dependenciesBlock = ExtractDependenciesBlock(content);
|
||||
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var config in DependencyConfigurations)
|
||||
{
|
||||
// Pattern 1: implementation 'group:artifact:version'
|
||||
var stringPattern = $@"{config}\s+['""]([^'""]+)['""]";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, stringPattern))
|
||||
{
|
||||
var coordinate = match.Groups[1].Value;
|
||||
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
|
||||
if (dependency is not null)
|
||||
{
|
||||
dependencies.Add(dependency);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(coordinate))
|
||||
{
|
||||
unresolved.Add(coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: implementation group: 'com.example', name: 'artifact', version: '1.0'
|
||||
var mapPattern = $@"{config}\s+group:\s*['""]([^'""]+)['""]\s*,\s*name:\s*['""]([^'""]+)['""]\s*(?:,\s*version:\s*['""]([^'""]+)['""])?";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, mapPattern))
|
||||
{
|
||||
var groupId = match.Groups[1].Value;
|
||||
var artifactId = match.Groups[2].Value;
|
||||
var version = match.Groups.Count > 3 && match.Groups[3].Success
|
||||
? match.Groups[3].Value
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
dependencies.Add(new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = groupId,
|
||||
ArtifactId = artifactId,
|
||||
Version = ResolveVersionProperty(version, properties),
|
||||
Scope = MapConfigurationToScope(config),
|
||||
Source = "build.gradle",
|
||||
Locator = sourcePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: implementation(libs.some.library) - version catalog reference
|
||||
var catalogPattern = $@"{config}\s*\(\s*libs\.([a-zA-Z0-9_.]+)\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, catalogPattern))
|
||||
{
|
||||
var alias = match.Groups[1].Value;
|
||||
// Mark as unresolved until version catalog is parsed
|
||||
unresolved.Add($"libs.{alias}");
|
||||
}
|
||||
|
||||
// Pattern 4: implementation("group:artifact:version") - with parentheses
|
||||
var parenPattern = $@"{config}\s*\(\s*['""]([^'""]+)['""]\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, parenPattern))
|
||||
{
|
||||
var coordinate = match.Groups[1].Value;
|
||||
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
|
||||
if (dependency is not null)
|
||||
{
|
||||
dependencies.Add(dependency);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(coordinate))
|
||||
{
|
||||
unresolved.Add(coordinate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParsePlatformDependencies(
|
||||
string content,
|
||||
string sourcePath,
|
||||
List<JavaDependencyDeclaration> dependencies)
|
||||
{
|
||||
var dependenciesBlock = ExtractDependenciesBlock(content);
|
||||
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Match: implementation platform('group:artifact:version')
|
||||
var platformPattern = @"(?:implementation|api)\s+platform\s*\(\s*['""]([^'""]+)['""]\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, platformPattern))
|
||||
{
|
||||
var coordinate = match.Groups[1].Value;
|
||||
var parts = coordinate.Split(':');
|
||||
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
dependencies.Add(new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = parts[0],
|
||||
ArtifactId = parts[1],
|
||||
Version = parts.Length > 2 ? parts[2] : null,
|
||||
Type = "pom",
|
||||
Scope = "import",
|
||||
Source = "build.gradle",
|
||||
Locator = sourcePath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractDependenciesBlock(string content)
|
||||
{
|
||||
// Simple extraction - find matching braces after 'dependencies'
|
||||
var match = DependenciesBlockPattern().Match(content);
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startIndex = match.Index + match.Length;
|
||||
var braceCount = 1;
|
||||
var endIndex = startIndex;
|
||||
|
||||
while (endIndex < content.Length && braceCount > 0)
|
||||
{
|
||||
if (content[endIndex] == '{') braceCount++;
|
||||
else if (content[endIndex] == '}') braceCount--;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
if (braceCount == 0)
|
||||
{
|
||||
return content[startIndex..(endIndex - 1)];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JavaDependencyDeclaration? ParseCoordinate(
|
||||
string coordinate,
|
||||
string configuration,
|
||||
string sourcePath,
|
||||
GradleProperties? properties)
|
||||
{
|
||||
var parts = coordinate.Split(':');
|
||||
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var groupId = parts[0];
|
||||
var artifactId = parts[1];
|
||||
var version = parts.Length > 2 ? parts[2] : null;
|
||||
string? classifier = null;
|
||||
|
||||
// Handle classifier: group:artifact:version:classifier
|
||||
if (parts.Length > 3)
|
||||
{
|
||||
classifier = parts[3];
|
||||
}
|
||||
|
||||
// Handle version ranges or dynamic versions
|
||||
if (version is not null && (version.Contains('[') || version.Contains('+') || version == "latest.release"))
|
||||
{
|
||||
// Keep dynamic versions as-is but mark them
|
||||
}
|
||||
|
||||
return new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = groupId,
|
||||
ArtifactId = artifactId,
|
||||
Version = ResolveVersionProperty(version, properties),
|
||||
Classifier = classifier,
|
||||
Scope = MapConfigurationToScope(configuration),
|
||||
Source = "build.gradle",
|
||||
Locator = sourcePath
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveVersionProperty(string? version, GradleProperties? properties)
|
||||
{
|
||||
if (version is null || properties is null)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
// Handle $property or ${property} syntax
|
||||
if (version.StartsWith('$'))
|
||||
{
|
||||
var propertyName = version.TrimStart('$').Trim('{', '}');
|
||||
return properties.GetProperty(propertyName) ?? version;
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
private static string MapConfigurationToScope(string configuration)
|
||||
{
|
||||
return configuration.ToLowerInvariant() switch
|
||||
{
|
||||
"implementation" or "api" or "compile" => "compile",
|
||||
"compileonly" or "providedcompile" => "provided",
|
||||
"runtimeonly" or "runtime" or "providedruntime" => "runtime",
|
||||
"testimplementation" or "testcompile" => "test",
|
||||
"testcompileonly" => "test",
|
||||
"testruntimeonly" or "testruntime" => "test",
|
||||
"annotationprocessor" or "kapt" or "ksp" => "compile",
|
||||
_ => "compile"
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"plugins\s*\{([^}]+)\}", RegexOptions.Singleline)]
|
||||
private static partial Regex PluginsBlockPattern();
|
||||
|
||||
[GeneratedRegex(@"id\s*['""]([^'""]+)['""]\s*(?:version\s*['""]([^'""]+)['""])?", RegexOptions.Singleline)]
|
||||
private static partial Regex PluginPattern();
|
||||
|
||||
[GeneratedRegex(@"dependencies\s*\{", RegexOptions.Multiline)]
|
||||
private static partial Regex DependenciesBlockPattern();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed Gradle build file.
|
||||
/// </summary>
|
||||
internal sealed record GradleBuildFile(
|
||||
string SourcePath,
|
||||
JavaBuildSystem BuildSystem,
|
||||
string? Group,
|
||||
string? Version,
|
||||
ImmutableArray<JavaDependencyDeclaration> Dependencies,
|
||||
ImmutableArray<GradlePlugin> Plugins,
|
||||
ImmutableArray<string> UnresolvedDependencies)
|
||||
{
|
||||
public static readonly GradleBuildFile Empty = new(
|
||||
string.Empty,
|
||||
JavaBuildSystem.GradleGroovy,
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
[],
|
||||
[]);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if parsing found any dependencies.
|
||||
/// </summary>
|
||||
public bool HasDependencies => Dependencies.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if there are unresolved dependencies.
|
||||
/// </summary>
|
||||
public bool HasUnresolvedDependencies => UnresolvedDependencies.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Gradle plugin declaration.
|
||||
/// </summary>
|
||||
internal sealed record GradlePlugin(string Id, string? Version);
|
||||
@@ -0,0 +1,375 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Gradle Kotlin DSL build files (build.gradle.kts).
|
||||
/// Uses regex-based parsing to extract dependency declarations.
|
||||
/// </summary>
|
||||
internal static partial class GradleKotlinParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Gradle Kotlin DSL configuration functions.
|
||||
/// </summary>
|
||||
private static readonly string[] DependencyConfigurations =
|
||||
[
|
||||
"implementation", "api", "compileOnly", "runtimeOnly",
|
||||
"testImplementation", "testCompileOnly", "testRuntimeOnly",
|
||||
"annotationProcessor", "kapt", "ksp"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Parses a build.gradle.kts file asynchronously.
|
||||
/// </summary>
|
||||
public static async Task<GradleBuildFile> ParseAsync(
|
||||
string path,
|
||||
GradleProperties? properties = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return GradleBuildFile.Empty;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, path, properties);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses build.gradle.kts content.
|
||||
/// </summary>
|
||||
public static GradleBuildFile Parse(string content, string sourcePath, GradleProperties? properties = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return GradleBuildFile.Empty;
|
||||
}
|
||||
|
||||
var dependencies = new List<JavaDependencyDeclaration>();
|
||||
var plugins = new List<GradlePlugin>();
|
||||
var unresolvedDependencies = new List<string>();
|
||||
|
||||
// Extract group and version
|
||||
var group = ExtractProperty(content, "group");
|
||||
var version = ExtractProperty(content, "version");
|
||||
|
||||
// Parse plugins block
|
||||
ParsePlugins(content, plugins);
|
||||
|
||||
// Parse dependencies block
|
||||
ParseDependencies(content, sourcePath, properties, dependencies, unresolvedDependencies);
|
||||
|
||||
// Parse platform/BOM declarations
|
||||
ParsePlatformDependencies(content, sourcePath, dependencies);
|
||||
|
||||
return new GradleBuildFile(
|
||||
sourcePath,
|
||||
JavaBuildSystem.GradleKotlin,
|
||||
group,
|
||||
version,
|
||||
[.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)],
|
||||
[.. plugins.OrderBy(p => p.Id, StringComparer.Ordinal)],
|
||||
[.. unresolvedDependencies.Distinct().OrderBy(u => u, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
private static string? ExtractProperty(string content, string propertyName)
|
||||
{
|
||||
// Match: group = "com.example" or group.set("com.example")
|
||||
var assignPattern = $@"{propertyName}\s*=\s*""([^""]+)""";
|
||||
var match = Regex.Match(content, assignPattern);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
|
||||
var setPattern = $@"{propertyName}\.set\s*\(\s*""([^""]+)""\s*\)";
|
||||
match = Regex.Match(content, setPattern);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
private static void ParsePlugins(string content, List<GradlePlugin> plugins)
|
||||
{
|
||||
// Match plugins { ... } block
|
||||
var pluginsBlock = ExtractBlock(content, "plugins");
|
||||
if (string.IsNullOrWhiteSpace(pluginsBlock))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Match id("plugin-id") version "x.y.z"
|
||||
foreach (Match match in PluginIdPattern().Matches(pluginsBlock))
|
||||
{
|
||||
var id = match.Groups[1].Value;
|
||||
var version = match.Groups.Count > 2 && match.Groups[2].Success
|
||||
? match.Groups[2].Value
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
plugins.Add(new GradlePlugin(id, version));
|
||||
}
|
||||
}
|
||||
|
||||
// Match kotlin("jvm") style
|
||||
foreach (Match match in KotlinPluginPattern().Matches(pluginsBlock))
|
||||
{
|
||||
var type = match.Groups[1].Value;
|
||||
var version = match.Groups.Count > 2 && match.Groups[2].Success
|
||||
? match.Groups[2].Value
|
||||
: null;
|
||||
|
||||
plugins.Add(new GradlePlugin($"org.jetbrains.kotlin.{type}", version));
|
||||
}
|
||||
|
||||
// Match `java` or similar bare plugins
|
||||
foreach (Match match in BarePluginPattern().Matches(pluginsBlock))
|
||||
{
|
||||
var id = match.Groups[1].Value;
|
||||
if (!id.Contains('"') && !id.Contains('('))
|
||||
{
|
||||
plugins.Add(new GradlePlugin(id, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseDependencies(
|
||||
string content,
|
||||
string sourcePath,
|
||||
GradleProperties? properties,
|
||||
List<JavaDependencyDeclaration> dependencies,
|
||||
List<string> unresolved)
|
||||
{
|
||||
var dependenciesBlock = ExtractBlock(content, "dependencies");
|
||||
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var config in DependencyConfigurations)
|
||||
{
|
||||
// Pattern 1: implementation("group:artifact:version")
|
||||
var stringPattern = $@"{config}\s*\(\s*""([^""]+)""\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, stringPattern))
|
||||
{
|
||||
var coordinate = match.Groups[1].Value;
|
||||
var dependency = ParseCoordinate(coordinate, config, sourcePath, properties);
|
||||
if (dependency is not null)
|
||||
{
|
||||
dependencies.Add(dependency);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(coordinate))
|
||||
{
|
||||
unresolved.Add(coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: implementation(group = "com.example", name = "artifact", version = "1.0")
|
||||
var namedArgsPattern = $@"{config}\s*\(\s*group\s*=\s*""([^""]+)""\s*,\s*name\s*=\s*""([^""]+)""(?:\s*,\s*version\s*=\s*""([^""]+)"")?\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, namedArgsPattern))
|
||||
{
|
||||
var groupId = match.Groups[1].Value;
|
||||
var artifactId = match.Groups[2].Value;
|
||||
var version = match.Groups.Count > 3 && match.Groups[3].Success
|
||||
? match.Groups[3].Value
|
||||
: null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
dependencies.Add(new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = groupId,
|
||||
ArtifactId = artifactId,
|
||||
Version = ResolveVersionProperty(version, properties),
|
||||
Scope = MapConfigurationToScope(config),
|
||||
Source = "build.gradle.kts",
|
||||
Locator = sourcePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: implementation(libs.some.library) - version catalog reference
|
||||
var catalogPattern = $@"{config}\s*\(\s*libs\.([a-zA-Z0-9_.]+)\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, catalogPattern))
|
||||
{
|
||||
var alias = match.Groups[1].Value;
|
||||
unresolved.Add($"libs.{alias}");
|
||||
}
|
||||
|
||||
// Pattern 4: implementation(project(":module"))
|
||||
var projectPattern = $@"{config}\s*\(\s*project\s*\(\s*"":([^""]+)""\s*\)\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, projectPattern))
|
||||
{
|
||||
// Skip project dependencies - they're internal modules
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParsePlatformDependencies(
|
||||
string content,
|
||||
string sourcePath,
|
||||
List<JavaDependencyDeclaration> dependencies)
|
||||
{
|
||||
var dependenciesBlock = ExtractBlock(content, "dependencies");
|
||||
if (string.IsNullOrWhiteSpace(dependenciesBlock))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Match: implementation(platform("group:artifact:version"))
|
||||
var platformPattern = @"(?:implementation|api)\s*\(\s*platform\s*\(\s*""([^""]+)""\s*\)\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, platformPattern))
|
||||
{
|
||||
var coordinate = match.Groups[1].Value;
|
||||
var parts = coordinate.Split(':');
|
||||
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
dependencies.Add(new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = parts[0],
|
||||
ArtifactId = parts[1],
|
||||
Version = parts.Length > 2 ? parts[2] : null,
|
||||
Type = "pom",
|
||||
Scope = "import",
|
||||
Source = "build.gradle.kts",
|
||||
Locator = sourcePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Match: implementation(enforcedPlatform("group:artifact:version"))
|
||||
var enforcedPattern = @"(?:implementation|api)\s*\(\s*enforcedPlatform\s*\(\s*""([^""]+)""\s*\)\s*\)";
|
||||
foreach (Match match in Regex.Matches(dependenciesBlock, enforcedPattern))
|
||||
{
|
||||
var coordinate = match.Groups[1].Value;
|
||||
var parts = coordinate.Split(':');
|
||||
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
dependencies.Add(new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = parts[0],
|
||||
ArtifactId = parts[1],
|
||||
Version = parts.Length > 2 ? parts[2] : null,
|
||||
Type = "pom",
|
||||
Scope = "import",
|
||||
Source = "build.gradle.kts",
|
||||
Locator = sourcePath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractBlock(string content, string blockName)
|
||||
{
|
||||
var pattern = $@"{blockName}\s*\{{";
|
||||
var match = Regex.Match(content, pattern);
|
||||
if (!match.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startIndex = match.Index + match.Length;
|
||||
var braceCount = 1;
|
||||
var endIndex = startIndex;
|
||||
|
||||
while (endIndex < content.Length && braceCount > 0)
|
||||
{
|
||||
if (content[endIndex] == '{') braceCount++;
|
||||
else if (content[endIndex] == '}') braceCount--;
|
||||
endIndex++;
|
||||
}
|
||||
|
||||
if (braceCount == 0)
|
||||
{
|
||||
return content[startIndex..(endIndex - 1)];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JavaDependencyDeclaration? ParseCoordinate(
|
||||
string coordinate,
|
||||
string configuration,
|
||||
string sourcePath,
|
||||
GradleProperties? properties)
|
||||
{
|
||||
// Handle string interpolation like "$group:$artifact:$version"
|
||||
if (coordinate.Contains('$'))
|
||||
{
|
||||
return null; // Unresolved variable reference
|
||||
}
|
||||
|
||||
var parts = coordinate.Split(':');
|
||||
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var groupId = parts[0];
|
||||
var artifactId = parts[1];
|
||||
var version = parts.Length > 2 ? parts[2] : null;
|
||||
string? classifier = null;
|
||||
|
||||
if (parts.Length > 3)
|
||||
{
|
||||
classifier = parts[3];
|
||||
}
|
||||
|
||||
return new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = groupId,
|
||||
ArtifactId = artifactId,
|
||||
Version = ResolveVersionProperty(version, properties),
|
||||
Classifier = classifier,
|
||||
Scope = MapConfigurationToScope(configuration),
|
||||
Source = "build.gradle.kts",
|
||||
Locator = sourcePath
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveVersionProperty(string? version, GradleProperties? properties)
|
||||
{
|
||||
if (version is null || properties is null)
|
||||
{
|
||||
return version;
|
||||
}
|
||||
|
||||
// Handle $property syntax in Kotlin
|
||||
if (version.StartsWith('$'))
|
||||
{
|
||||
var propertyName = version.TrimStart('$');
|
||||
return properties.GetProperty(propertyName) ?? version;
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
private static string MapConfigurationToScope(string configuration)
|
||||
{
|
||||
return configuration.ToLowerInvariant() switch
|
||||
{
|
||||
"implementation" or "api" => "compile",
|
||||
"compileonly" => "provided",
|
||||
"runtimeonly" => "runtime",
|
||||
"testimplementation" => "test",
|
||||
"testcompileonly" or "testruntimeonly" => "test",
|
||||
"annotationprocessor" or "kapt" or "ksp" => "compile",
|
||||
_ => "compile"
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"id\s*\(\s*""([^""]+)""\s*\)(?:\s*version\s*""([^""]+)"")?", RegexOptions.Singleline)]
|
||||
private static partial Regex PluginIdPattern();
|
||||
|
||||
[GeneratedRegex(@"kotlin\s*\(\s*""([^""]+)""\s*\)(?:\s*version\s*""([^""]+)"")?", RegexOptions.Singleline)]
|
||||
private static partial Regex KotlinPluginPattern();
|
||||
|
||||
[GeneratedRegex(@"^\s*`?([a-zA-Z-]+)`?\s*$", RegexOptions.Multiline)]
|
||||
private static partial Regex BarePluginPattern();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||
|
||||
/// <summary>
|
||||
/// Parses gradle.properties files to extract key-value properties.
|
||||
/// </summary>
|
||||
internal static partial class GradlePropertiesParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a gradle.properties file asynchronously.
|
||||
/// </summary>
|
||||
public static async Task<GradleProperties> ParseAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return GradleProperties.Empty;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses gradle.properties content.
|
||||
/// </summary>
|
||||
public static GradleProperties Parse(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return GradleProperties.Empty;
|
||||
}
|
||||
|
||||
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var systemProperties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
using var reader = new StringReader(content);
|
||||
string? line;
|
||||
string? continuationKey = null;
|
||||
var continuationValue = new System.Text.StringBuilder();
|
||||
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
// Handle line continuation
|
||||
if (continuationKey is not null)
|
||||
{
|
||||
if (line.EndsWith('\\'))
|
||||
{
|
||||
continuationValue.Append(line[..^1]);
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
continuationValue.Append(line);
|
||||
AddProperty(properties, systemProperties, continuationKey, continuationValue.ToString());
|
||||
continuationKey = null;
|
||||
continuationValue.Clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim and skip empty lines/comments
|
||||
line = line.Trim();
|
||||
if (string.IsNullOrEmpty(line) || line.StartsWith('#') || line.StartsWith('!'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the key-value separator (= or :)
|
||||
var separatorIndex = FindSeparator(line);
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..separatorIndex].Trim();
|
||||
var value = line[(separatorIndex + 1)..].TrimStart();
|
||||
|
||||
// Handle line continuation
|
||||
if (value.EndsWith('\\'))
|
||||
{
|
||||
continuationKey = key;
|
||||
continuationValue.Append(value[..^1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
AddProperty(properties, systemProperties, key, value);
|
||||
}
|
||||
|
||||
// Handle any remaining continuation
|
||||
if (continuationKey is not null)
|
||||
{
|
||||
AddProperty(properties, systemProperties, continuationKey, continuationValue.ToString());
|
||||
}
|
||||
|
||||
return new GradleProperties(
|
||||
properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
systemProperties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static int FindSeparator(string line)
|
||||
{
|
||||
var equalsIndex = line.IndexOf('=');
|
||||
var colonIndex = line.IndexOf(':');
|
||||
|
||||
if (equalsIndex < 0) return colonIndex;
|
||||
if (colonIndex < 0) return equalsIndex;
|
||||
return Math.Min(equalsIndex, colonIndex);
|
||||
}
|
||||
|
||||
private static void AddProperty(
|
||||
Dictionary<string, string> properties,
|
||||
Dictionary<string, string> systemProperties,
|
||||
string key,
|
||||
string value)
|
||||
{
|
||||
// Unescape common escape sequences
|
||||
value = UnescapeValue(value);
|
||||
|
||||
// Check if it's a system property
|
||||
if (key.StartsWith("systemProp.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var systemKey = key["systemProp.".Length..];
|
||||
systemProperties[systemKey] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
properties[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static string UnescapeValue(string value)
|
||||
{
|
||||
if (!value.Contains('\\'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value
|
||||
.Replace("\\n", "\n")
|
||||
.Replace("\\r", "\r")
|
||||
.Replace("\\t", "\t")
|
||||
.Replace("\\\\", "\\");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents parsed gradle.properties content.
|
||||
/// </summary>
|
||||
internal sealed record GradleProperties(
|
||||
ImmutableDictionary<string, string> Properties,
|
||||
ImmutableDictionary<string, string> SystemProperties)
|
||||
{
|
||||
public static readonly GradleProperties Empty = new(
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a property value, returning null if not found.
|
||||
/// </summary>
|
||||
public string? GetProperty(string key)
|
||||
=> Properties.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the project group if defined.
|
||||
/// </summary>
|
||||
public string? Group => GetProperty("group");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the project version if defined.
|
||||
/// </summary>
|
||||
public string? Version => GetProperty("version");
|
||||
|
||||
/// <summary>
|
||||
/// Gets commonly used version properties.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, string>> GetVersionProperties()
|
||||
{
|
||||
foreach (var (key, value) in Properties)
|
||||
{
|
||||
if (key.EndsWith("Version", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.EndsWith(".version", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return new KeyValuePair<string, string>(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Gradle Version Catalog files (libs.versions.toml).
|
||||
/// </summary>
|
||||
internal static class GradleVersionCatalogParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a version catalog file asynchronously.
|
||||
/// </summary>
|
||||
public static async Task<GradleVersionCatalog> ParseAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return GradleVersionCatalog.Empty;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses version catalog content.
|
||||
/// </summary>
|
||||
public static GradleVersionCatalog Parse(string content, string sourcePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return GradleVersionCatalog.Empty;
|
||||
}
|
||||
|
||||
var document = TomlParser.Parse(content);
|
||||
|
||||
var versions = ParseVersions(document);
|
||||
var libraries = ParseLibraries(document, versions, sourcePath);
|
||||
var plugins = ParsePlugins(document, versions);
|
||||
var bundles = ParseBundles(document);
|
||||
|
||||
return new GradleVersionCatalog(
|
||||
sourcePath,
|
||||
versions.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
libraries.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
plugins.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
bundles.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseVersions(TomlDocument document)
|
||||
{
|
||||
var versions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var versionsTable = document.GetTable("versions");
|
||||
if (versionsTable is null)
|
||||
{
|
||||
return versions;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in versionsTable.Entries)
|
||||
{
|
||||
if (value.Kind == TomlValueKind.String)
|
||||
{
|
||||
versions[key] = value.StringValue;
|
||||
}
|
||||
else if (value.Kind == TomlValueKind.InlineTable)
|
||||
{
|
||||
// Handle { strictly = "x.y.z" } or { prefer = "x.y.z" }
|
||||
var strictly = value.GetNestedString("strictly");
|
||||
var prefer = value.GetNestedString("prefer");
|
||||
var require = value.GetNestedString("require");
|
||||
|
||||
versions[key] = strictly ?? prefer ?? require ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
private static Dictionary<string, CatalogLibrary> ParseLibraries(
|
||||
TomlDocument document,
|
||||
Dictionary<string, string> versions,
|
||||
string sourcePath)
|
||||
{
|
||||
var libraries = new Dictionary<string, CatalogLibrary>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var librariesTable = document.GetTable("libraries");
|
||||
if (librariesTable is null)
|
||||
{
|
||||
return libraries;
|
||||
}
|
||||
|
||||
foreach (var (alias, value) in librariesTable.Entries)
|
||||
{
|
||||
CatalogLibrary? library = null;
|
||||
|
||||
if (value.Kind == TomlValueKind.String)
|
||||
{
|
||||
// Short notation: "group:artifact:version"
|
||||
library = ParseLibraryString(alias, value.StringValue, sourcePath);
|
||||
}
|
||||
else if (value.Kind == TomlValueKind.InlineTable)
|
||||
{
|
||||
// Full notation with module or group/name
|
||||
library = ParseLibraryTable(alias, value, versions, sourcePath);
|
||||
}
|
||||
|
||||
if (library is not null)
|
||||
{
|
||||
libraries[alias] = library;
|
||||
}
|
||||
}
|
||||
|
||||
return libraries;
|
||||
}
|
||||
|
||||
private static CatalogLibrary? ParseLibraryString(string alias, string value, string sourcePath)
|
||||
{
|
||||
var parts = value.Split(':');
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CatalogLibrary(
|
||||
alias,
|
||||
parts[0],
|
||||
parts[1],
|
||||
parts.Length > 2 ? parts[2] : null,
|
||||
null,
|
||||
sourcePath);
|
||||
}
|
||||
|
||||
private static CatalogLibrary? ParseLibraryTable(
|
||||
string alias,
|
||||
TomlValue value,
|
||||
Dictionary<string, string> versions,
|
||||
string sourcePath)
|
||||
{
|
||||
var module = value.GetNestedString("module");
|
||||
string? groupId = null;
|
||||
string? artifactId = null;
|
||||
string? version = null;
|
||||
string? versionRef = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(module))
|
||||
{
|
||||
// module = "group:artifact"
|
||||
var parts = module.Split(':');
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
groupId = parts[0];
|
||||
artifactId = parts[1];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// group = "...", name = "..."
|
||||
groupId = value.GetNestedString("group");
|
||||
artifactId = value.GetNestedString("name");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(groupId) || string.IsNullOrEmpty(artifactId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle version - can be direct or reference
|
||||
version = value.GetNestedString("version");
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
// Check for version.ref
|
||||
var versionValue = value.TableValue?.GetValueOrDefault("version");
|
||||
if (versionValue?.Kind == TomlValueKind.InlineTable)
|
||||
{
|
||||
versionRef = versionValue.GetNestedString("ref");
|
||||
if (!string.IsNullOrEmpty(versionRef) && versions.TryGetValue(versionRef, out var resolvedVersion))
|
||||
{
|
||||
version = resolvedVersion;
|
||||
}
|
||||
}
|
||||
else if (versionValue?.Kind == TomlValueKind.String)
|
||||
{
|
||||
version = versionValue.StringValue;
|
||||
}
|
||||
}
|
||||
|
||||
return new CatalogLibrary(
|
||||
alias,
|
||||
groupId,
|
||||
artifactId,
|
||||
version,
|
||||
versionRef,
|
||||
sourcePath);
|
||||
}
|
||||
|
||||
private static Dictionary<string, CatalogPlugin> ParsePlugins(
|
||||
TomlDocument document,
|
||||
Dictionary<string, string> versions)
|
||||
{
|
||||
var plugins = new Dictionary<string, CatalogPlugin>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var pluginsTable = document.GetTable("plugins");
|
||||
if (pluginsTable is null)
|
||||
{
|
||||
return plugins;
|
||||
}
|
||||
|
||||
foreach (var (alias, value) in pluginsTable.Entries)
|
||||
{
|
||||
if (value.Kind == TomlValueKind.String)
|
||||
{
|
||||
// Short notation: "plugin.id:version"
|
||||
var parts = value.StringValue.Split(':');
|
||||
plugins[alias] = new CatalogPlugin(
|
||||
alias,
|
||||
parts[0],
|
||||
parts.Length > 1 ? parts[1] : null,
|
||||
null);
|
||||
}
|
||||
else if (value.Kind == TomlValueKind.InlineTable)
|
||||
{
|
||||
var id = value.GetNestedString("id");
|
||||
var version = value.GetNestedString("version");
|
||||
string? versionRef = null;
|
||||
|
||||
if (string.IsNullOrEmpty(version))
|
||||
{
|
||||
var versionValue = value.TableValue?.GetValueOrDefault("version");
|
||||
if (versionValue?.Kind == TomlValueKind.InlineTable)
|
||||
{
|
||||
versionRef = versionValue.GetNestedString("ref");
|
||||
if (!string.IsNullOrEmpty(versionRef) && versions.TryGetValue(versionRef, out var resolved))
|
||||
{
|
||||
version = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
plugins[alias] = new CatalogPlugin(alias, id, version, versionRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
private static Dictionary<string, CatalogBundle> ParseBundles(TomlDocument document)
|
||||
{
|
||||
var bundles = new Dictionary<string, CatalogBundle>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var bundlesTable = document.GetTable("bundles");
|
||||
if (bundlesTable is null)
|
||||
{
|
||||
return bundles;
|
||||
}
|
||||
|
||||
foreach (var (alias, value) in bundlesTable.Entries)
|
||||
{
|
||||
if (value.Kind == TomlValueKind.Array)
|
||||
{
|
||||
var libraryRefs = value.GetArrayItems()
|
||||
.Where(v => v.Kind == TomlValueKind.String)
|
||||
.Select(v => v.StringValue)
|
||||
.ToImmutableArray();
|
||||
|
||||
bundles[alias] = new CatalogBundle(alias, libraryRefs);
|
||||
}
|
||||
}
|
||||
|
||||
return bundles;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed Gradle Version Catalog.
|
||||
/// </summary>
|
||||
internal sealed record GradleVersionCatalog(
|
||||
string SourcePath,
|
||||
FrozenDictionary<string, string> Versions,
|
||||
FrozenDictionary<string, CatalogLibrary> Libraries,
|
||||
FrozenDictionary<string, CatalogPlugin> Plugins,
|
||||
FrozenDictionary<string, CatalogBundle> Bundles)
|
||||
{
|
||||
public static readonly GradleVersionCatalog Empty = new(
|
||||
string.Empty,
|
||||
FrozenDictionary<string, string>.Empty,
|
||||
FrozenDictionary<string, CatalogLibrary>.Empty,
|
||||
FrozenDictionary<string, CatalogPlugin>.Empty,
|
||||
FrozenDictionary<string, CatalogBundle>.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the catalog has any libraries.
|
||||
/// </summary>
|
||||
public bool HasLibraries => Libraries.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a library by its alias.
|
||||
/// </summary>
|
||||
public CatalogLibrary? GetLibrary(string alias)
|
||||
{
|
||||
// Handle dotted notation: libs.some.library -> some-library or some.library
|
||||
var normalizedAlias = alias
|
||||
.Replace("libs.", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace('.', '-');
|
||||
|
||||
if (Libraries.TryGetValue(normalizedAlias, out var library))
|
||||
{
|
||||
return library;
|
||||
}
|
||||
|
||||
// Try with dots
|
||||
normalizedAlias = alias.Replace("libs.", "", StringComparison.OrdinalIgnoreCase);
|
||||
return Libraries.TryGetValue(normalizedAlias, out library) ? library : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts all libraries to dependency declarations.
|
||||
/// </summary>
|
||||
public IEnumerable<JavaDependencyDeclaration> ToDependencies()
|
||||
{
|
||||
foreach (var library in Libraries.Values)
|
||||
{
|
||||
yield return new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = library.GroupId,
|
||||
ArtifactId = library.ArtifactId,
|
||||
Version = library.Version,
|
||||
VersionSource = library.VersionRef is not null
|
||||
? JavaVersionSource.VersionCatalog
|
||||
: JavaVersionSource.Direct,
|
||||
VersionProperty = library.VersionRef,
|
||||
Source = "libs.versions.toml",
|
||||
Locator = SourcePath
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a library entry in the version catalog.
|
||||
/// </summary>
|
||||
internal sealed record CatalogLibrary(
|
||||
string Alias,
|
||||
string GroupId,
|
||||
string ArtifactId,
|
||||
string? Version,
|
||||
string? VersionRef,
|
||||
string SourcePath)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate.
|
||||
/// </summary>
|
||||
public string Gav => Version is not null
|
||||
? $"{GroupId}:{ArtifactId}:{Version}"
|
||||
: $"{GroupId}:{ArtifactId}";
|
||||
|
||||
/// <summary>
|
||||
/// Converts to a dependency declaration.
|
||||
/// </summary>
|
||||
public JavaDependencyDeclaration ToDependency(string? scope = null) => new()
|
||||
{
|
||||
GroupId = GroupId,
|
||||
ArtifactId = ArtifactId,
|
||||
Version = Version,
|
||||
Scope = scope,
|
||||
VersionSource = VersionRef is not null
|
||||
? JavaVersionSource.VersionCatalog
|
||||
: JavaVersionSource.Direct,
|
||||
VersionProperty = VersionRef,
|
||||
Source = "libs.versions.toml",
|
||||
Locator = SourcePath
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plugin entry in the version catalog.
|
||||
/// </summary>
|
||||
internal sealed record CatalogPlugin(
|
||||
string Alias,
|
||||
string Id,
|
||||
string? Version,
|
||||
string? VersionRef);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a bundle (group of libraries) in the version catalog.
|
||||
/// </summary>
|
||||
internal sealed record CatalogBundle(
|
||||
string Alias,
|
||||
ImmutableArray<string> LibraryRefs);
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Gradle;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal TOML parser for parsing Gradle version catalog files.
|
||||
/// Supports the subset of TOML needed for libs.versions.toml parsing.
|
||||
/// </summary>
|
||||
internal static partial class TomlParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a TOML file.
|
||||
/// </summary>
|
||||
public static TomlDocument Parse(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return TomlDocument.Empty;
|
||||
}
|
||||
|
||||
var tables = new Dictionary<string, TomlTable>(StringComparer.OrdinalIgnoreCase);
|
||||
var rootTable = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
|
||||
var currentTable = rootTable;
|
||||
var currentTableName = string.Empty;
|
||||
|
||||
using var reader = new StringReader(content);
|
||||
string? line;
|
||||
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
line = line.Trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table header: [tableName]
|
||||
var tableMatch = TableHeaderPattern().Match(line);
|
||||
if (tableMatch.Success)
|
||||
{
|
||||
// Save previous table
|
||||
if (!string.IsNullOrEmpty(currentTableName))
|
||||
{
|
||||
tables[currentTableName] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
else if (currentTable.Count > 0)
|
||||
{
|
||||
tables[string.Empty] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
currentTableName = tableMatch.Groups[1].Value;
|
||||
currentTable = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Key-value pair: key = value
|
||||
var kvMatch = KeyValuePattern().Match(line);
|
||||
if (kvMatch.Success)
|
||||
{
|
||||
var key = kvMatch.Groups[1].Value.Trim().Trim('"');
|
||||
var valueStr = kvMatch.Groups[2].Value.Trim();
|
||||
var value = ParseValue(valueStr);
|
||||
currentTable[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the last table
|
||||
if (!string.IsNullOrEmpty(currentTableName))
|
||||
{
|
||||
tables[currentTableName] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
else if (currentTable.Count > 0)
|
||||
{
|
||||
tables[string.Empty] = new TomlTable(currentTable.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return new TomlDocument(tables.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static TomlValue ParseValue(string valueStr)
|
||||
{
|
||||
// Remove trailing comment
|
||||
var commentIndex = valueStr.IndexOf('#');
|
||||
if (commentIndex > 0)
|
||||
{
|
||||
// But not inside a string
|
||||
var inString = false;
|
||||
for (int i = 0; i < commentIndex; i++)
|
||||
{
|
||||
if (valueStr[i] == '"' && (i == 0 || valueStr[i - 1] != '\\'))
|
||||
{
|
||||
inString = !inString;
|
||||
}
|
||||
}
|
||||
if (!inString)
|
||||
{
|
||||
valueStr = valueStr[..commentIndex].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// String value: "value" or 'value'
|
||||
if ((valueStr.StartsWith('"') && valueStr.EndsWith('"')) ||
|
||||
(valueStr.StartsWith('\'') && valueStr.EndsWith('\'')))
|
||||
{
|
||||
return new TomlValue(TomlValueKind.String, valueStr[1..^1]);
|
||||
}
|
||||
|
||||
// Inline table: { key = "value", ... }
|
||||
if (valueStr.StartsWith('{') && valueStr.EndsWith('}'))
|
||||
{
|
||||
var tableContent = valueStr[1..^1];
|
||||
var inlineTable = ParseInlineTable(tableContent);
|
||||
return new TomlValue(TomlValueKind.InlineTable, valueStr, inlineTable);
|
||||
}
|
||||
|
||||
// Array: [ ... ]
|
||||
if (valueStr.StartsWith('[') && valueStr.EndsWith(']'))
|
||||
{
|
||||
var arrayContent = valueStr[1..^1];
|
||||
var items = ParseArray(arrayContent);
|
||||
return new TomlValue(TomlValueKind.Array, valueStr, ArrayItems: items);
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (valueStr.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TomlValue(TomlValueKind.Boolean, "true");
|
||||
}
|
||||
if (valueStr.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TomlValue(TomlValueKind.Boolean, "false");
|
||||
}
|
||||
|
||||
// Number (integer or float)
|
||||
if (double.TryParse(valueStr, out _))
|
||||
{
|
||||
return new TomlValue(TomlValueKind.Number, valueStr);
|
||||
}
|
||||
|
||||
// Bare string (unquoted - technically not valid TOML but seen in some files)
|
||||
return new TomlValue(TomlValueKind.String, valueStr);
|
||||
}
|
||||
|
||||
private static FrozenDictionary<string, TomlValue> ParseInlineTable(string content)
|
||||
{
|
||||
var result = new Dictionary<string, TomlValue>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Split by comma, handling nested structures
|
||||
var pairs = SplitByComma(content);
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var eqIndex = pair.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = pair[..eqIndex].Trim().Trim('"');
|
||||
var valueStr = pair[(eqIndex + 1)..].Trim();
|
||||
result[key] = ParseValue(valueStr);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ImmutableArray<TomlValue> ParseArray(string content)
|
||||
{
|
||||
var items = new List<TomlValue>();
|
||||
var elements = SplitByComma(content);
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
var trimmed = element.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
items.Add(ParseValue(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
return [.. items];
|
||||
}
|
||||
|
||||
private static List<string> SplitByComma(string content)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
var depth = 0;
|
||||
var inString = false;
|
||||
|
||||
foreach (var c in content)
|
||||
{
|
||||
if (c == '"' && (current.Length == 0 || current[^1] != '\\'))
|
||||
{
|
||||
inString = !inString;
|
||||
}
|
||||
|
||||
if (!inString)
|
||||
{
|
||||
if (c == '{' || c == '[') depth++;
|
||||
else if (c == '}' || c == ']') depth--;
|
||||
else if (c == ',' && depth == 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current.Append(c);
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\[([^\]]+)\]$")]
|
||||
private static partial Regex TableHeaderPattern();
|
||||
|
||||
[GeneratedRegex(@"^([^=]+)=(.+)$")]
|
||||
private static partial Regex KeyValuePattern();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed TOML document.
|
||||
/// </summary>
|
||||
internal sealed record TomlDocument(FrozenDictionary<string, TomlTable> Tables)
|
||||
{
|
||||
public static readonly TomlDocument Empty = new(FrozenDictionary<string, TomlTable>.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a table by name.
|
||||
/// </summary>
|
||||
public TomlTable? GetTable(string name)
|
||||
=> Tables.TryGetValue(name, out var table) ? table : null;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a table exists.
|
||||
/// </summary>
|
||||
public bool HasTable(string name)
|
||||
=> Tables.ContainsKey(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a TOML table (section).
|
||||
/// </summary>
|
||||
internal sealed record TomlTable(FrozenDictionary<string, TomlValue> Values)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a string value from the table.
|
||||
/// </summary>
|
||||
public string? GetString(string key)
|
||||
=> Values.TryGetValue(key, out var value) && value.Kind == TomlValueKind.String
|
||||
? value.StringValue
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets an inline table value.
|
||||
/// </summary>
|
||||
public FrozenDictionary<string, TomlValue>? GetInlineTable(string key)
|
||||
=> Values.TryGetValue(key, out var value) && value.Kind == TomlValueKind.InlineTable
|
||||
? value.TableValue
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entries in this table.
|
||||
/// </summary>
|
||||
public IEnumerable<KeyValuePair<string, TomlValue>> Entries => Values;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a TOML value.
|
||||
/// </summary>
|
||||
internal sealed record TomlValue(
|
||||
TomlValueKind Kind,
|
||||
string StringValue,
|
||||
FrozenDictionary<string, TomlValue>? TableValue = null,
|
||||
ImmutableArray<TomlValue>? ArrayItems = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a nested value from an inline table.
|
||||
/// </summary>
|
||||
public string? GetNestedString(string key)
|
||||
{
|
||||
if (Kind != TomlValueKind.InlineTable || TableValue is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return TableValue.TryGetValue(key, out var value) ? value.StringValue : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the array items if this is an array value.
|
||||
/// </summary>
|
||||
public ImmutableArray<TomlValue> GetArrayItems()
|
||||
=> ArrayItems ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kind of TOML value.
|
||||
/// </summary>
|
||||
internal enum TomlValueKind
|
||||
{
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
Array,
|
||||
InlineTable
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.License;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes license names and URLs to SPDX identifiers.
|
||||
/// </summary>
|
||||
internal sealed partial class SpdxLicenseNormalizer
|
||||
{
|
||||
private static readonly Lazy<SpdxLicenseNormalizer> LazyInstance = new(() => new SpdxLicenseNormalizer());
|
||||
|
||||
private readonly FrozenDictionary<string, SpdxLicenseMapping> _nameIndex;
|
||||
private readonly FrozenDictionary<string, SpdxLicenseMapping> _urlIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the singleton instance.
|
||||
/// </summary>
|
||||
public static SpdxLicenseNormalizer Instance => LazyInstance.Value;
|
||||
|
||||
private SpdxLicenseNormalizer()
|
||||
{
|
||||
var mappings = LoadMappings();
|
||||
|
||||
var nameDict = new Dictionary<string, SpdxLicenseMapping>(StringComparer.OrdinalIgnoreCase);
|
||||
var urlDict = new Dictionary<string, SpdxLicenseMapping>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var mapping in mappings)
|
||||
{
|
||||
// Index by normalized name
|
||||
foreach (var name in mapping.Names)
|
||||
{
|
||||
var normalizedName = NormalizeName(name);
|
||||
nameDict.TryAdd(normalizedName, mapping);
|
||||
}
|
||||
|
||||
// Index by URL
|
||||
foreach (var url in mapping.Urls)
|
||||
{
|
||||
var normalizedUrl = NormalizeUrl(url);
|
||||
urlDict.TryAdd(normalizedUrl, mapping);
|
||||
}
|
||||
}
|
||||
|
||||
_nameIndex = nameDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
_urlIndex = urlDict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a license name and/or URL to an SPDX identifier.
|
||||
/// </summary>
|
||||
public JavaLicenseInfo Normalize(string? name, string? url)
|
||||
{
|
||||
var result = new JavaLicenseInfo
|
||||
{
|
||||
Name = name,
|
||||
Url = url
|
||||
};
|
||||
|
||||
// Try URL first (higher confidence)
|
||||
if (!string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
var normalizedUrl = NormalizeUrl(url);
|
||||
if (_urlIndex.TryGetValue(normalizedUrl, out var urlMapping))
|
||||
{
|
||||
return result with
|
||||
{
|
||||
SpdxId = urlMapping.SpdxId,
|
||||
SpdxConfidence = SpdxConfidence.High
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then try name
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var normalizedName = NormalizeName(name);
|
||||
|
||||
// Exact match
|
||||
if (_nameIndex.TryGetValue(normalizedName, out var nameMapping))
|
||||
{
|
||||
return result with
|
||||
{
|
||||
SpdxId = nameMapping.SpdxId,
|
||||
SpdxConfidence = SpdxConfidence.High
|
||||
};
|
||||
}
|
||||
|
||||
// Fuzzy match
|
||||
var fuzzyMatch = TryFuzzyMatch(normalizedName);
|
||||
if (fuzzyMatch is not null)
|
||||
{
|
||||
return result with
|
||||
{
|
||||
SpdxId = fuzzyMatch.SpdxId,
|
||||
SpdxConfidence = SpdxConfidence.Medium
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name)
|
||||
{
|
||||
// Remove common noise words and normalize whitespace
|
||||
var normalized = name.ToLowerInvariant()
|
||||
.Replace("the", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("license", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("licence", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("version", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(",", "")
|
||||
.Replace("(", "")
|
||||
.Replace(")", "");
|
||||
|
||||
return WhitespacePattern().Replace(normalized, " ").Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
// Normalize URL for comparison
|
||||
var normalized = url.ToLowerInvariant()
|
||||
.Replace("https://", "")
|
||||
.Replace("http://", "")
|
||||
.Replace("www.", "")
|
||||
.TrimEnd('/');
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private SpdxLicenseMapping? TryFuzzyMatch(string normalizedName)
|
||||
{
|
||||
// Check for common patterns
|
||||
if (normalizedName.Contains("apache") && normalizedName.Contains("2"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("apache 2.0");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("mit"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("mit");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("bsd") && normalizedName.Contains("3"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("bsd 3 clause");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("bsd") && normalizedName.Contains("2"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("bsd 2 clause");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("gpl") && normalizedName.Contains("3"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("gpl 3.0");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("gpl") && normalizedName.Contains("2"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("gpl 2.0");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("lgpl") && normalizedName.Contains("2.1"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("lgpl 2.1");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("lgpl") && normalizedName.Contains("3"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("lgpl 3.0");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("mpl") && normalizedName.Contains("2"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("mpl 2.0");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("cddl"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("cddl 1.0");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("epl") && normalizedName.Contains("2"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("epl 2.0");
|
||||
}
|
||||
|
||||
if (normalizedName.Contains("epl") && normalizedName.Contains("1"))
|
||||
{
|
||||
return _nameIndex.GetValueOrDefault("epl 1.0");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<SpdxLicenseMapping> LoadMappings()
|
||||
{
|
||||
// High-confidence SPDX mappings for common licenses
|
||||
// This list focuses on licenses commonly found in Java/Maven projects
|
||||
return
|
||||
[
|
||||
// Apache
|
||||
new SpdxLicenseMapping("Apache-2.0",
|
||||
["Apache License 2.0", "Apache License, Version 2.0", "Apache 2.0", "Apache-2.0", "ASL 2.0", "AL 2.0"],
|
||||
["apache.org/licenses/LICENSE-2.0", "opensource.org/licenses/Apache-2.0"]),
|
||||
|
||||
new SpdxLicenseMapping("Apache-1.1",
|
||||
["Apache License 1.1", "Apache Software License 1.1"],
|
||||
["apache.org/licenses/LICENSE-1.1"]),
|
||||
|
||||
// MIT
|
||||
new SpdxLicenseMapping("MIT",
|
||||
["MIT License", "MIT", "The MIT License", "Expat License"],
|
||||
["opensource.org/licenses/MIT", "mit-license.org"]),
|
||||
|
||||
// BSD
|
||||
new SpdxLicenseMapping("BSD-2-Clause",
|
||||
["BSD 2-Clause License", "BSD-2-Clause", "Simplified BSD License", "FreeBSD License"],
|
||||
["opensource.org/licenses/BSD-2-Clause"]),
|
||||
|
||||
new SpdxLicenseMapping("BSD-3-Clause",
|
||||
["BSD 3-Clause License", "BSD-3-Clause", "New BSD License", "Modified BSD License"],
|
||||
["opensource.org/licenses/BSD-3-Clause"]),
|
||||
|
||||
// GPL
|
||||
new SpdxLicenseMapping("GPL-2.0-only",
|
||||
["GNU General Public License v2.0", "GPL 2.0", "GPL-2.0", "GPLv2"],
|
||||
["gnu.org/licenses/old-licenses/gpl-2.0", "opensource.org/licenses/GPL-2.0"]),
|
||||
|
||||
new SpdxLicenseMapping("GPL-2.0-or-later",
|
||||
["GNU General Public License v2.0 or later", "GPL 2.0+", "GPL-2.0+", "GPLv2+"],
|
||||
[]),
|
||||
|
||||
new SpdxLicenseMapping("GPL-3.0-only",
|
||||
["GNU General Public License v3.0", "GPL 3.0", "GPL-3.0", "GPLv3"],
|
||||
["gnu.org/licenses/gpl-3.0", "opensource.org/licenses/GPL-3.0"]),
|
||||
|
||||
new SpdxLicenseMapping("GPL-3.0-or-later",
|
||||
["GNU General Public License v3.0 or later", "GPL 3.0+", "GPL-3.0+", "GPLv3+"],
|
||||
[]),
|
||||
|
||||
// LGPL
|
||||
new SpdxLicenseMapping("LGPL-2.1-only",
|
||||
["GNU Lesser General Public License v2.1", "LGPL 2.1", "LGPL-2.1", "LGPLv2.1"],
|
||||
["gnu.org/licenses/old-licenses/lgpl-2.1", "opensource.org/licenses/LGPL-2.1"]),
|
||||
|
||||
new SpdxLicenseMapping("LGPL-3.0-only",
|
||||
["GNU Lesser General Public License v3.0", "LGPL 3.0", "LGPL-3.0", "LGPLv3"],
|
||||
["gnu.org/licenses/lgpl-3.0", "opensource.org/licenses/LGPL-3.0"]),
|
||||
|
||||
// MPL
|
||||
new SpdxLicenseMapping("MPL-2.0",
|
||||
["Mozilla Public License 2.0", "MPL 2.0", "MPL-2.0"],
|
||||
["mozilla.org/MPL/2.0", "opensource.org/licenses/MPL-2.0"]),
|
||||
|
||||
new SpdxLicenseMapping("MPL-1.1",
|
||||
["Mozilla Public License 1.1", "MPL 1.1", "MPL-1.1"],
|
||||
["mozilla.org/MPL/1.1"]),
|
||||
|
||||
// Eclipse
|
||||
new SpdxLicenseMapping("EPL-1.0",
|
||||
["Eclipse Public License 1.0", "EPL 1.0", "EPL-1.0"],
|
||||
["eclipse.org/legal/epl-v10", "opensource.org/licenses/EPL-1.0"]),
|
||||
|
||||
new SpdxLicenseMapping("EPL-2.0",
|
||||
["Eclipse Public License 2.0", "EPL 2.0", "EPL-2.0"],
|
||||
["eclipse.org/legal/epl-2.0", "opensource.org/licenses/EPL-2.0"]),
|
||||
|
||||
// CDDL
|
||||
new SpdxLicenseMapping("CDDL-1.0",
|
||||
["Common Development and Distribution License 1.0", "CDDL 1.0", "CDDL-1.0"],
|
||||
["opensource.org/licenses/CDDL-1.0"]),
|
||||
|
||||
new SpdxLicenseMapping("CDDL-1.1",
|
||||
["Common Development and Distribution License 1.1", "CDDL 1.1", "CDDL-1.1"],
|
||||
["glassfish.dev.java.net/public/CDDL+GPL_1_1"]),
|
||||
|
||||
// Creative Commons
|
||||
new SpdxLicenseMapping("CC0-1.0",
|
||||
["CC0 1.0 Universal", "CC0", "Public Domain"],
|
||||
["creativecommons.org/publicdomain/zero/1.0"]),
|
||||
|
||||
new SpdxLicenseMapping("CC-BY-4.0",
|
||||
["Creative Commons Attribution 4.0", "CC BY 4.0"],
|
||||
["creativecommons.org/licenses/by/4.0"]),
|
||||
|
||||
// Unlicense
|
||||
new SpdxLicenseMapping("Unlicense",
|
||||
["The Unlicense", "Unlicense"],
|
||||
["unlicense.org"]),
|
||||
|
||||
// ISC
|
||||
new SpdxLicenseMapping("ISC",
|
||||
["ISC License", "ISC"],
|
||||
["opensource.org/licenses/ISC"]),
|
||||
|
||||
// Zlib
|
||||
new SpdxLicenseMapping("Zlib",
|
||||
["zlib License", "zlib/libpng License"],
|
||||
["opensource.org/licenses/Zlib"]),
|
||||
|
||||
// WTFPL
|
||||
new SpdxLicenseMapping("WTFPL",
|
||||
["Do What The F*ck You Want To Public License", "WTFPL"],
|
||||
["wtfpl.net"]),
|
||||
|
||||
// BSL (Business Source License)
|
||||
new SpdxLicenseMapping("BSL-1.0",
|
||||
["Boost Software License 1.0", "BSL-1.0", "Boost License"],
|
||||
["boost.org/LICENSE_1_0.txt", "opensource.org/licenses/BSL-1.0"]),
|
||||
|
||||
// JSON License
|
||||
new SpdxLicenseMapping("JSON",
|
||||
["The JSON License", "JSON License"],
|
||||
["json.org/license"]),
|
||||
|
||||
// AGPL
|
||||
new SpdxLicenseMapping("AGPL-3.0-only",
|
||||
["GNU Affero General Public License v3.0", "AGPL 3.0", "AGPL-3.0", "AGPLv3"],
|
||||
["gnu.org/licenses/agpl-3.0", "opensource.org/licenses/AGPL-3.0"]),
|
||||
|
||||
// PostgreSQL
|
||||
new SpdxLicenseMapping("PostgreSQL",
|
||||
["PostgreSQL License", "The PostgreSQL License"],
|
||||
["opensource.org/licenses/PostgreSQL"]),
|
||||
|
||||
// Unicode
|
||||
new SpdxLicenseMapping("Unicode-DFS-2016",
|
||||
["Unicode License Agreement", "Unicode DFS 2016"],
|
||||
["unicode.org/copyright"]),
|
||||
|
||||
// W3C
|
||||
new SpdxLicenseMapping("W3C",
|
||||
["W3C Software Notice and License", "W3C License"],
|
||||
["w3.org/Consortium/Legal/2015/copyright-software-and-document"])
|
||||
];
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex WhitespacePattern();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a mapping from license names/URLs to an SPDX identifier.
|
||||
/// </summary>
|
||||
internal sealed record SpdxLicenseMapping(
|
||||
string SpdxId,
|
||||
IReadOnlyList<string> Names,
|
||||
IReadOnlyList<string> Urls);
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
/// <summary>
|
||||
/// Imports Maven BOM (Bill of Materials) POMs to extract managed dependency versions.
|
||||
/// </summary>
|
||||
internal sealed class MavenBomImporter
|
||||
{
|
||||
private const int MaxImportDepth = 5;
|
||||
|
||||
private readonly string _rootPath;
|
||||
private readonly MavenLocalRepository _localRepository;
|
||||
private readonly Dictionary<string, ImportedBom?> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _importing = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public MavenBomImporter(string rootPath)
|
||||
{
|
||||
_rootPath = rootPath;
|
||||
_localRepository = new MavenLocalRepository();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports a BOM and returns its managed dependencies.
|
||||
/// </summary>
|
||||
public async Task<ImportedBom?> ImportAsync(
|
||||
string groupId,
|
||||
string artifactId,
|
||||
string version,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await ImportInternalAsync(groupId, artifactId, version, 0, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<ImportedBom?> ImportInternalAsync(
|
||||
string groupId,
|
||||
string artifactId,
|
||||
string version,
|
||||
int depth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (depth >= MaxImportDepth)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = $"{groupId}:{artifactId}:{version}".ToLowerInvariant();
|
||||
|
||||
// Check cache
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check for cycle
|
||||
if (!_importing.Add(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bomPom = await TryLoadBomAsync(groupId, artifactId, version, cancellationToken).ConfigureAwait(false);
|
||||
if (bomPom is null)
|
||||
{
|
||||
_cache[key] = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
var managedDependencies = new List<JavaDependencyDeclaration>();
|
||||
var nestedBoms = new List<ImportedBom>();
|
||||
|
||||
// Process dependency management
|
||||
foreach (var dep in bomPom.DependencyManagement)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Check if this is a nested BOM import
|
||||
if (dep.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
|
||||
dep.Type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var nestedBom = await ImportInternalAsync(
|
||||
dep.GroupId,
|
||||
dep.ArtifactId,
|
||||
dep.Version ?? string.Empty,
|
||||
depth + 1,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (nestedBom is not null)
|
||||
{
|
||||
nestedBoms.Add(nestedBom);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
managedDependencies.Add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge nested BOM dependencies (earlier BOMs have lower priority)
|
||||
var allManaged = new Dictionary<string, JavaDependencyDeclaration>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var nestedBom in nestedBoms)
|
||||
{
|
||||
foreach (var dep in nestedBom.ManagedDependencies)
|
||||
{
|
||||
var depKey = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||
allManaged.TryAdd(depKey, dep);
|
||||
}
|
||||
}
|
||||
|
||||
// Current BOM's declarations override nested
|
||||
foreach (var dep in managedDependencies)
|
||||
{
|
||||
var depKey = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||
allManaged[depKey] = dep;
|
||||
}
|
||||
|
||||
var result = new ImportedBom(
|
||||
groupId,
|
||||
artifactId,
|
||||
version,
|
||||
bomPom.SourcePath,
|
||||
bomPom.Properties,
|
||||
[.. allManaged.Values.OrderBy(d => d.Gav, StringComparer.Ordinal)],
|
||||
[.. nestedBoms]);
|
||||
|
||||
_cache[key] = result;
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_importing.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<MavenPom?> TryLoadBomAsync(
|
||||
string groupId,
|
||||
string artifactId,
|
||||
string version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Try local Maven repository first
|
||||
var localPath = _localRepository.GetPomPath(groupId, artifactId, version);
|
||||
if (localPath is not null && File.Exists(localPath))
|
||||
{
|
||||
return await MavenPomParser.ParseAsync(localPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Try to find in workspace
|
||||
var workspacePath = FindInWorkspace(groupId, artifactId);
|
||||
if (workspacePath is not null)
|
||||
{
|
||||
return await MavenPomParser.ParseAsync(workspacePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? FindInWorkspace(string groupId, string artifactId)
|
||||
{
|
||||
// Search for pom.xml files that match the GAV
|
||||
try
|
||||
{
|
||||
foreach (var pomPath in Directory.EnumerateFiles(_rootPath, "pom.xml", SearchOption.AllDirectories))
|
||||
{
|
||||
// Quick check by reading first few KB
|
||||
var content = File.ReadAllText(pomPath);
|
||||
if (content.Contains($"<groupId>{groupId}</groupId>", StringComparison.OrdinalIgnoreCase) &&
|
||||
content.Contains($"<artifactId>{artifactId}</artifactId>", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return pomPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore file system errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an imported BOM with its managed dependencies.
|
||||
/// </summary>
|
||||
internal sealed record ImportedBom(
|
||||
string GroupId,
|
||||
string ArtifactId,
|
||||
string Version,
|
||||
string SourcePath,
|
||||
ImmutableDictionary<string, string> Properties,
|
||||
ImmutableArray<JavaDependencyDeclaration> ManagedDependencies,
|
||||
ImmutableArray<ImportedBom> NestedBoms)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate.
|
||||
/// </summary>
|
||||
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a managed version for an artifact.
|
||||
/// </summary>
|
||||
public string? GetManagedVersion(string groupId, string artifactId)
|
||||
{
|
||||
var key = $"{groupId}:{artifactId}".ToLowerInvariant();
|
||||
return ManagedDependencies
|
||||
.FirstOrDefault(d => $"{d.GroupId}:{d.ArtifactId}".Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
?.Version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an effective POM by merging the parent chain and resolving all properties.
|
||||
/// </summary>
|
||||
internal sealed class MavenEffectivePomBuilder
|
||||
{
|
||||
private readonly MavenParentResolver _parentResolver;
|
||||
private readonly MavenBomImporter _bomImporter;
|
||||
|
||||
public MavenEffectivePomBuilder(string rootPath)
|
||||
{
|
||||
_parentResolver = new MavenParentResolver(rootPath);
|
||||
_bomImporter = new MavenBomImporter(rootPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the effective POM with fully resolved dependencies.
|
||||
/// </summary>
|
||||
public async Task<MavenEffectivePomResult> BuildAsync(
|
||||
MavenPom pom,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Step 1: Resolve parent chain
|
||||
var effectivePom = await _parentResolver.ResolveAsync(pom, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 2: Import BOMs from dependency management
|
||||
var bomImports = await ImportBomsAsync(pom, effectivePom.EffectiveProperties, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Step 3: Build merged dependency management index
|
||||
var managedVersions = BuildManagedVersionsIndex(effectivePom, bomImports);
|
||||
|
||||
// Step 4: Create property resolver with all properties
|
||||
var allProperties = MergeProperties(effectivePom.EffectiveProperties, bomImports);
|
||||
var resolver = new JavaPropertyResolver(allProperties);
|
||||
|
||||
// Step 5: Resolve all dependencies
|
||||
var resolvedDependencies = ResolveDependencies(
|
||||
pom.Dependencies,
|
||||
managedVersions,
|
||||
resolver);
|
||||
|
||||
return new MavenEffectivePomResult(
|
||||
pom,
|
||||
effectivePom.ParentChain,
|
||||
allProperties,
|
||||
managedVersions.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
|
||||
resolvedDependencies,
|
||||
effectivePom.AllLicenses,
|
||||
bomImports,
|
||||
effectivePom.UnresolvedParents);
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<ImportedBom>> ImportBomsAsync(
|
||||
MavenPom pom,
|
||||
ImmutableDictionary<string, string> properties,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bomImports = pom.GetBomImports().ToList();
|
||||
if (bomImports.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var resolver = new JavaPropertyResolver(properties);
|
||||
var imported = new List<ImportedBom>();
|
||||
|
||||
foreach (var bomDep in bomImports)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Resolve version if it contains properties
|
||||
var version = bomDep.Version;
|
||||
if (version?.Contains("${", StringComparison.Ordinal) == true)
|
||||
{
|
||||
var result = resolver.Resolve(version);
|
||||
version = result.ResolvedValue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var bom = await _bomImporter.ImportAsync(
|
||||
bomDep.GroupId,
|
||||
bomDep.ArtifactId,
|
||||
version,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bom is not null)
|
||||
{
|
||||
imported.Add(bom);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. imported];
|
||||
}
|
||||
|
||||
private static Dictionary<string, ManagedDependency> BuildManagedVersionsIndex(
|
||||
MavenEffectivePom effectivePom,
|
||||
ImmutableArray<ImportedBom> bomImports)
|
||||
{
|
||||
var index = new Dictionary<string, ManagedDependency>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Start with BOM imports (lower priority)
|
||||
foreach (var bom in bomImports)
|
||||
{
|
||||
foreach (var managed in bom.ManagedDependencies)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||
{
|
||||
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||
index.TryAdd(key, new ManagedDependency(
|
||||
managed.Version,
|
||||
$"bom:{bom.GroupId}:{bom.ArtifactId}:{bom.Version}",
|
||||
managed.Scope));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then parent chain (higher priority, child overrides parent)
|
||||
for (int i = effectivePom.ParentChain.Length - 1; i >= 0; i--)
|
||||
{
|
||||
var parentPom = effectivePom.ParentChain[i];
|
||||
foreach (var managed in parentPom.DependencyManagement)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||
{
|
||||
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||
index[key] = new ManagedDependency(
|
||||
managed.Version,
|
||||
i == 0 ? "dependencyManagement" : $"parent:{parentPom.Gav}",
|
||||
managed.Scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally current POM's dependency management (highest priority)
|
||||
foreach (var managed in effectivePom.OriginalPom.DependencyManagement)
|
||||
{
|
||||
// Skip BOM imports themselves
|
||||
if (managed.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||
{
|
||||
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||
index[key] = new ManagedDependency(
|
||||
managed.Version,
|
||||
"dependencyManagement",
|
||||
managed.Scope);
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> MergeProperties(
|
||||
ImmutableDictionary<string, string> effectiveProperties,
|
||||
ImmutableArray<ImportedBom> bomImports)
|
||||
{
|
||||
var merged = effectiveProperties.ToBuilder();
|
||||
|
||||
// Add properties from BOMs (don't override existing)
|
||||
foreach (var bom in bomImports)
|
||||
{
|
||||
foreach (var (key, value) in bom.Properties)
|
||||
{
|
||||
merged.TryAdd(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return merged.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<JavaDependencyDeclaration> ResolveDependencies(
|
||||
ImmutableArray<JavaDependencyDeclaration> dependencies,
|
||||
Dictionary<string, ManagedDependency> managedVersions,
|
||||
JavaPropertyResolver resolver)
|
||||
{
|
||||
var resolved = new List<JavaDependencyDeclaration>();
|
||||
|
||||
foreach (var dep in dependencies)
|
||||
{
|
||||
var resolvedDep = dep;
|
||||
var versionSource = dep.VersionSource;
|
||||
string? versionProperty = dep.VersionProperty;
|
||||
|
||||
// Resolve property placeholders in version
|
||||
if (dep.Version?.Contains("${", StringComparison.Ordinal) == true)
|
||||
{
|
||||
var result = resolver.Resolve(dep.Version);
|
||||
resolvedDep = dep with { Version = result.ResolvedValue };
|
||||
versionSource = result.IsFullyResolved
|
||||
? JavaVersionSource.Property
|
||||
: JavaVersionSource.Unresolved;
|
||||
versionProperty = ExtractPropertyName(dep.Version);
|
||||
}
|
||||
// Look up version from managed dependencies
|
||||
else if (string.IsNullOrWhiteSpace(dep.Version))
|
||||
{
|
||||
var key = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||
if (managedVersions.TryGetValue(key, out var managed))
|
||||
{
|
||||
// Resolve any properties in the managed version
|
||||
var managedVersion = managed.Version;
|
||||
if (managedVersion.Contains("${", StringComparison.Ordinal))
|
||||
{
|
||||
var result = resolver.Resolve(managedVersion);
|
||||
managedVersion = result.ResolvedValue;
|
||||
}
|
||||
|
||||
resolvedDep = dep with
|
||||
{
|
||||
Version = managedVersion,
|
||||
Scope = dep.Scope ?? managed.Scope
|
||||
};
|
||||
|
||||
versionSource = managed.Source.StartsWith("bom:", StringComparison.Ordinal)
|
||||
? JavaVersionSource.Bom
|
||||
: managed.Source == "dependencyManagement"
|
||||
? JavaVersionSource.DependencyManagement
|
||||
: JavaVersionSource.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
resolved.Add(resolvedDep with
|
||||
{
|
||||
VersionSource = versionSource,
|
||||
VersionProperty = versionProperty
|
||||
});
|
||||
}
|
||||
|
||||
return [.. resolved.OrderBy(d => d.Gav, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static string? ExtractPropertyName(string value)
|
||||
{
|
||||
var start = value.IndexOf("${", StringComparison.Ordinal);
|
||||
var end = value.IndexOf('}', start + 2);
|
||||
|
||||
if (start >= 0 && end > start)
|
||||
{
|
||||
return value[(start + 2)..end];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of building an effective POM.
|
||||
/// </summary>
|
||||
internal sealed record MavenEffectivePomResult(
|
||||
MavenPom OriginalPom,
|
||||
ImmutableArray<MavenPom> ParentChain,
|
||||
ImmutableDictionary<string, string> EffectiveProperties,
|
||||
ImmutableDictionary<string, ManagedDependency> ManagedVersions,
|
||||
ImmutableArray<JavaDependencyDeclaration> ResolvedDependencies,
|
||||
ImmutableArray<JavaLicenseInfo> Licenses,
|
||||
ImmutableArray<ImportedBom> ImportedBoms,
|
||||
ImmutableArray<string> UnresolvedParents)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if all parents and BOMs were resolved.
|
||||
/// </summary>
|
||||
public bool IsFullyResolved => UnresolvedParents.Length == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets dependencies that still have unresolved versions.
|
||||
/// </summary>
|
||||
public IEnumerable<JavaDependencyDeclaration> GetUnresolvedDependencies()
|
||||
=> ResolvedDependencies.Where(d => !d.IsVersionResolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a managed dependency version.
|
||||
/// </summary>
|
||||
internal sealed record ManagedDependency(
|
||||
string Version,
|
||||
string Source,
|
||||
string? Scope);
|
||||
@@ -0,0 +1,334 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves Maven parent POM chain and builds effective POM properties.
|
||||
/// </summary>
|
||||
internal sealed class MavenParentResolver
|
||||
{
|
||||
private const int MaxDepth = 10;
|
||||
|
||||
private readonly string _rootPath;
|
||||
private readonly Dictionary<string, MavenPom> _pomCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public MavenParentResolver(string rootPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
_rootPath = rootPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the parent chain for a POM and returns the effective properties.
|
||||
/// </summary>
|
||||
public async Task<MavenEffectivePom> ResolveAsync(
|
||||
MavenPom pom,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var chain = new List<MavenPom> { pom };
|
||||
var unresolved = new List<string>();
|
||||
|
||||
// Build the parent chain
|
||||
await BuildParentChainAsync(pom, chain, unresolved, 0, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Merge properties from all POMs in the chain (parent to child)
|
||||
var effectiveProperties = BuildEffectiveProperties(chain);
|
||||
|
||||
// Build the property resolver
|
||||
var resolver = new JavaPropertyResolver(effectiveProperties);
|
||||
|
||||
// Resolve dependencies with merged properties
|
||||
var resolvedDependencies = ResolveDependencies(pom, chain, resolver);
|
||||
|
||||
// Collect all licenses from the chain
|
||||
var licenses = chain
|
||||
.SelectMany(p => p.Licenses)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
return new MavenEffectivePom(
|
||||
pom,
|
||||
[.. chain],
|
||||
effectiveProperties,
|
||||
resolvedDependencies,
|
||||
licenses,
|
||||
[.. unresolved]);
|
||||
}
|
||||
|
||||
private async Task BuildParentChainAsync(
|
||||
MavenPom pom,
|
||||
List<MavenPom> chain,
|
||||
List<string> unresolved,
|
||||
int depth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (depth >= MaxDepth || pom.Parent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var parent = pom.Parent;
|
||||
var parentPom = await TryResolveParentAsync(pom, parent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (parentPom is null)
|
||||
{
|
||||
unresolved.Add(parent.GroupId + ":" + parent.ArtifactId + ":" + parent.Version);
|
||||
return;
|
||||
}
|
||||
|
||||
chain.Add(parentPom);
|
||||
|
||||
// Recurse for grandparent
|
||||
await BuildParentChainAsync(parentPom, chain, unresolved, depth + 1, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<MavenPom?> TryResolveParentAsync(
|
||||
MavenPom childPom,
|
||||
MavenParentRef parent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Try relativePath first
|
||||
if (!string.IsNullOrWhiteSpace(parent.RelativePath))
|
||||
{
|
||||
var childDir = Path.GetDirectoryName(childPom.SourcePath) ?? _rootPath;
|
||||
var relativePomPath = Path.GetFullPath(Path.Combine(childDir, parent.RelativePath));
|
||||
|
||||
// If relativePath points to a directory, append pom.xml
|
||||
if (Directory.Exists(relativePomPath))
|
||||
{
|
||||
relativePomPath = Path.Combine(relativePomPath, "pom.xml");
|
||||
}
|
||||
|
||||
var parentPom = await TryLoadPomAsync(relativePomPath, cancellationToken).ConfigureAwait(false);
|
||||
if (parentPom is not null && MatchesParent(parentPom, parent))
|
||||
{
|
||||
return parentPom;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: look in parent directory
|
||||
var defaultPath = Path.GetFullPath(Path.Combine(
|
||||
Path.GetDirectoryName(childPom.SourcePath) ?? _rootPath,
|
||||
"..",
|
||||
"pom.xml"));
|
||||
|
||||
var defaultParent = await TryLoadPomAsync(defaultPath, cancellationToken).ConfigureAwait(false);
|
||||
if (defaultParent is not null && MatchesParent(defaultParent, parent))
|
||||
{
|
||||
return defaultParent;
|
||||
}
|
||||
|
||||
// Try to find in workspace by GAV
|
||||
var workspaceParent = await TryFindInWorkspaceAsync(parent, cancellationToken).ConfigureAwait(false);
|
||||
if (workspaceParent is not null)
|
||||
{
|
||||
return workspaceParent;
|
||||
}
|
||||
|
||||
// Try local Maven repository
|
||||
var localRepoParent = await TryFindInLocalRepositoryAsync(parent, cancellationToken).ConfigureAwait(false);
|
||||
return localRepoParent;
|
||||
}
|
||||
|
||||
private async Task<MavenPom?> TryLoadPomAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
|
||||
if (_pomCache.TryGetValue(normalizedPath, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var pom = await MavenPomParser.ParseAsync(normalizedPath, cancellationToken).ConfigureAwait(false);
|
||||
_pomCache[normalizedPath] = pom;
|
||||
return pom;
|
||||
}
|
||||
|
||||
private async Task<MavenPom?> TryFindInWorkspaceAsync(
|
||||
MavenParentRef parent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Search for pom.xml files in the workspace
|
||||
foreach (var pomPath in Directory.EnumerateFiles(_rootPath, "pom.xml", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pom = await TryLoadPomAsync(pomPath, cancellationToken).ConfigureAwait(false);
|
||||
if (pom is not null && MatchesParent(pom, parent))
|
||||
{
|
||||
return pom;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<MavenPom?> TryFindInLocalRepositoryAsync(
|
||||
MavenParentRef parent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var localRepoPath = GetLocalRepositoryPath();
|
||||
if (string.IsNullOrEmpty(localRepoPath) || !Directory.Exists(localRepoPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert GAV to path: com.example:parent:1.0.0 -> com/example/parent/1.0.0/parent-1.0.0.pom
|
||||
var groupPath = parent.GroupId.Replace('.', Path.DirectorySeparatorChar);
|
||||
var pomFileName = $"{parent.ArtifactId}-{parent.Version}.pom";
|
||||
var pomPath = Path.Combine(localRepoPath, groupPath, parent.ArtifactId, parent.Version, pomFileName);
|
||||
|
||||
return await TryLoadPomAsync(pomPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string? GetLocalRepositoryPath()
|
||||
{
|
||||
// Check M2_REPO environment variable
|
||||
var m2Repo = Environment.GetEnvironmentVariable("M2_REPO");
|
||||
if (!string.IsNullOrEmpty(m2Repo) && Directory.Exists(m2Repo))
|
||||
{
|
||||
return m2Repo;
|
||||
}
|
||||
|
||||
// Default: ~/.m2/repository
|
||||
var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var defaultPath = Path.Combine(userHome, ".m2", "repository");
|
||||
|
||||
return Directory.Exists(defaultPath) ? defaultPath : null;
|
||||
}
|
||||
|
||||
private static bool MatchesParent(MavenPom pom, MavenParentRef parent)
|
||||
{
|
||||
return string.Equals(pom.GroupId, parent.GroupId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(pom.ArtifactId, parent.ArtifactId, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> BuildEffectiveProperties(List<MavenPom> chain)
|
||||
{
|
||||
var builder = new JavaPropertyBuilder();
|
||||
|
||||
// Start from root parent and work down to child (child properties override parent)
|
||||
for (int i = chain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var pom = chain[i];
|
||||
|
||||
// Add project coordinates
|
||||
builder.AddProjectCoordinates(pom.GroupId, pom.ArtifactId, pom.Version);
|
||||
|
||||
// Add parent coordinates
|
||||
if (pom.Parent is not null)
|
||||
{
|
||||
builder.Add("project.parent.groupId", pom.Parent.GroupId);
|
||||
builder.Add("project.parent.artifactId", pom.Parent.ArtifactId);
|
||||
builder.Add("project.parent.version", pom.Parent.Version);
|
||||
}
|
||||
|
||||
// Add declared properties
|
||||
builder.AddRange(pom.Properties);
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static ImmutableArray<JavaDependencyDeclaration> ResolveDependencies(
|
||||
MavenPom pom,
|
||||
List<MavenPom> chain,
|
||||
JavaPropertyResolver resolver)
|
||||
{
|
||||
// Build dependency management index from all POMs in chain
|
||||
var managedVersions = BuildManagedVersionsIndex(chain);
|
||||
|
||||
var resolved = new List<JavaDependencyDeclaration>();
|
||||
|
||||
foreach (var dep in pom.Dependencies)
|
||||
{
|
||||
var resolvedDep = dep;
|
||||
|
||||
// Resolve property placeholders in version
|
||||
if (dep.Version?.Contains("${", StringComparison.Ordinal) == true)
|
||||
{
|
||||
var result = resolver.Resolve(dep.Version);
|
||||
resolvedDep = dep with
|
||||
{
|
||||
Version = result.ResolvedValue,
|
||||
VersionSource = result.IsFullyResolved
|
||||
? JavaVersionSource.Property
|
||||
: JavaVersionSource.Unresolved
|
||||
};
|
||||
}
|
||||
// Look up version from dependency management
|
||||
else if (string.IsNullOrWhiteSpace(dep.Version))
|
||||
{
|
||||
var key = $"{dep.GroupId}:{dep.ArtifactId}".ToLowerInvariant();
|
||||
if (managedVersions.TryGetValue(key, out var managedVersion))
|
||||
{
|
||||
// Resolve any properties in the managed version
|
||||
var result = resolver.Resolve(managedVersion);
|
||||
resolvedDep = dep with
|
||||
{
|
||||
Version = result.ResolvedValue,
|
||||
VersionSource = JavaVersionSource.DependencyManagement
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
resolved.Add(resolvedDep);
|
||||
}
|
||||
|
||||
return [.. resolved.OrderBy(d => d.Gav, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildManagedVersionsIndex(List<MavenPom> chain)
|
||||
{
|
||||
var index = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Start from root parent (last in chain) so child definitions override
|
||||
for (int i = chain.Count - 1; i >= 0; i--)
|
||||
{
|
||||
foreach (var managed in chain[i].DependencyManagement)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(managed.Version))
|
||||
{
|
||||
var key = $"{managed.GroupId}:{managed.ArtifactId}".ToLowerInvariant();
|
||||
index[key] = managed.Version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a fully resolved effective POM with merged parent chain.
|
||||
/// </summary>
|
||||
internal sealed record MavenEffectivePom(
|
||||
MavenPom OriginalPom,
|
||||
ImmutableArray<MavenPom> ParentChain,
|
||||
ImmutableDictionary<string, string> EffectiveProperties,
|
||||
ImmutableArray<JavaDependencyDeclaration> ResolvedDependencies,
|
||||
ImmutableArray<JavaLicenseInfo> AllLicenses,
|
||||
ImmutableArray<string> UnresolvedParents)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if all parents were successfully resolved.
|
||||
/// </summary>
|
||||
public bool IsFullyResolved => UnresolvedParents.Length == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective group ID.
|
||||
/// </summary>
|
||||
public string? EffectiveGroupId => OriginalPom.GroupId ?? ParentChain.FirstOrDefault()?.GroupId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective version.
|
||||
/// </summary>
|
||||
public string? EffectiveVersion => OriginalPom.Version ?? ParentChain.FirstOrDefault()?.Version;
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Xml.Linq;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.License;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Maven;
|
||||
|
||||
/// <summary>
|
||||
/// Parses Maven POM files (pom.xml) to extract project metadata and dependencies.
|
||||
/// </summary>
|
||||
internal static class MavenPomParser
|
||||
{
|
||||
private static readonly XNamespace PomNamespace = "http://maven.apache.org/POM/4.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Parses a pom.xml file asynchronously.
|
||||
/// </summary>
|
||||
public static async Task<MavenPom> ParseAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return MavenPom.Empty;
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var document = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Parse(document, path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses pom.xml content from a string.
|
||||
/// </summary>
|
||||
public static MavenPom ParseFromString(string content, string sourcePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return MavenPom.Empty;
|
||||
}
|
||||
|
||||
var document = XDocument.Parse(content);
|
||||
return Parse(document, sourcePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a pom.xml XDocument.
|
||||
/// </summary>
|
||||
public static MavenPom Parse(XDocument document, string sourcePath)
|
||||
{
|
||||
var root = document.Root;
|
||||
if (root is null)
|
||||
{
|
||||
return MavenPom.Empty;
|
||||
}
|
||||
|
||||
// Determine namespace (might be default or prefixed)
|
||||
var ns = root.Name.Namespace;
|
||||
if (ns == XNamespace.None)
|
||||
{
|
||||
ns = string.Empty;
|
||||
}
|
||||
|
||||
var groupId = GetElementValue(root, ns, "groupId");
|
||||
var artifactId = GetElementValue(root, ns, "artifactId");
|
||||
var version = GetElementValue(root, ns, "version");
|
||||
var packaging = GetElementValue(root, ns, "packaging") ?? "jar";
|
||||
var name = GetElementValue(root, ns, "name");
|
||||
var description = GetElementValue(root, ns, "description");
|
||||
|
||||
// Parse parent
|
||||
var parent = ParseParent(root, ns);
|
||||
|
||||
// Inherit from parent if not set
|
||||
groupId ??= parent?.GroupId;
|
||||
version ??= parent?.Version;
|
||||
|
||||
// Parse properties
|
||||
var properties = ParseProperties(root, ns);
|
||||
|
||||
// Parse licenses
|
||||
var licenses = ParseLicenses(root, ns);
|
||||
|
||||
// Parse dependencies
|
||||
var dependencies = ParseDependencies(root, ns, sourcePath);
|
||||
|
||||
// Parse dependency management
|
||||
var dependencyManagement = ParseDependencyManagement(root, ns, sourcePath);
|
||||
|
||||
// Parse modules (for multi-module projects)
|
||||
var modules = ParseModules(root, ns);
|
||||
|
||||
// Parse repositories
|
||||
var repositories = ParseRepositories(root, ns);
|
||||
|
||||
return new MavenPom(
|
||||
sourcePath,
|
||||
groupId,
|
||||
artifactId,
|
||||
version,
|
||||
packaging,
|
||||
name,
|
||||
description,
|
||||
parent,
|
||||
properties,
|
||||
licenses,
|
||||
dependencies,
|
||||
dependencyManagement,
|
||||
modules,
|
||||
repositories);
|
||||
}
|
||||
|
||||
private static string? GetElementValue(XElement parent, XNamespace ns, string name)
|
||||
{
|
||||
var element = parent.Element(ns + name);
|
||||
return element?.Value?.Trim();
|
||||
}
|
||||
|
||||
private static MavenParentRef? ParseParent(XElement root, XNamespace ns)
|
||||
{
|
||||
var parentElement = root.Element(ns + "parent");
|
||||
if (parentElement is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var groupId = GetElementValue(parentElement, ns, "groupId");
|
||||
var artifactId = GetElementValue(parentElement, ns, "artifactId");
|
||||
var version = GetElementValue(parentElement, ns, "version");
|
||||
var relativePath = GetElementValue(parentElement, ns, "relativePath");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new MavenParentRef(groupId, artifactId, version ?? string.Empty, relativePath);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> ParseProperties(XElement root, XNamespace ns)
|
||||
{
|
||||
var propertiesElement = root.Element(ns + "properties");
|
||||
if (propertiesElement is null)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var prop in propertiesElement.Elements())
|
||||
{
|
||||
var key = prop.Name.LocalName;
|
||||
var value = prop.Value?.Trim();
|
||||
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
properties[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ImmutableArray<JavaLicenseInfo> ParseLicenses(XElement root, XNamespace ns)
|
||||
{
|
||||
var licensesElement = root.Element(ns + "licenses");
|
||||
if (licensesElement is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var licenses = new List<JavaLicenseInfo>();
|
||||
|
||||
foreach (var licenseElement in licensesElement.Elements(ns + "license"))
|
||||
{
|
||||
var name = GetElementValue(licenseElement, ns, "name");
|
||||
var url = GetElementValue(licenseElement, ns, "url");
|
||||
var distribution = GetElementValue(licenseElement, ns, "distribution");
|
||||
var comments = GetElementValue(licenseElement, ns, "comments");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name) || !string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
// Normalize to SPDX
|
||||
var normalizedLicense = SpdxLicenseNormalizer.Instance.Normalize(name, url);
|
||||
|
||||
licenses.Add(normalizedLicense with
|
||||
{
|
||||
Distribution = distribution,
|
||||
Comments = comments
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [.. licenses];
|
||||
}
|
||||
|
||||
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencies(
|
||||
XElement root,
|
||||
XNamespace ns,
|
||||
string sourcePath)
|
||||
{
|
||||
var dependenciesElement = root.Element(ns + "dependencies");
|
||||
if (dependenciesElement is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return ParseDependencyElements(dependenciesElement, ns, sourcePath);
|
||||
}
|
||||
|
||||
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencyManagement(
|
||||
XElement root,
|
||||
XNamespace ns,
|
||||
string sourcePath)
|
||||
{
|
||||
var dmElement = root.Element(ns + "dependencyManagement");
|
||||
if (dmElement is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var dependenciesElement = dmElement.Element(ns + "dependencies");
|
||||
if (dependenciesElement is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return ParseDependencyElements(dependenciesElement, ns, sourcePath, isDependencyManagement: true);
|
||||
}
|
||||
|
||||
private static ImmutableArray<JavaDependencyDeclaration> ParseDependencyElements(
|
||||
XElement dependenciesElement,
|
||||
XNamespace ns,
|
||||
string sourcePath,
|
||||
bool isDependencyManagement = false)
|
||||
{
|
||||
var dependencies = new List<JavaDependencyDeclaration>();
|
||||
|
||||
foreach (var depElement in dependenciesElement.Elements(ns + "dependency"))
|
||||
{
|
||||
var groupId = GetElementValue(depElement, ns, "groupId");
|
||||
var artifactId = GetElementValue(depElement, ns, "artifactId");
|
||||
var version = GetElementValue(depElement, ns, "version");
|
||||
var scope = GetElementValue(depElement, ns, "scope");
|
||||
var type = GetElementValue(depElement, ns, "type");
|
||||
var classifier = GetElementValue(depElement, ns, "classifier");
|
||||
var optional = GetElementValue(depElement, ns, "optional");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse exclusions
|
||||
var exclusions = ParseExclusions(depElement, ns);
|
||||
|
||||
// Determine version source
|
||||
var versionSource = JavaVersionSource.Direct;
|
||||
string? versionProperty = null;
|
||||
|
||||
if (version?.Contains("${", StringComparison.Ordinal) == true)
|
||||
{
|
||||
versionSource = JavaVersionSource.Property;
|
||||
versionProperty = ExtractPropertyName(version);
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(version) && !isDependencyManagement)
|
||||
{
|
||||
versionSource = JavaVersionSource.DependencyManagement;
|
||||
}
|
||||
|
||||
// Check if this is a BOM import
|
||||
var isBomImport = scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
|
||||
type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
dependencies.Add(new JavaDependencyDeclaration
|
||||
{
|
||||
GroupId = groupId,
|
||||
ArtifactId = artifactId,
|
||||
Version = version,
|
||||
Scope = isBomImport ? "import" : scope,
|
||||
Type = type,
|
||||
Classifier = classifier,
|
||||
Optional = optional?.Equals("true", StringComparison.OrdinalIgnoreCase) == true,
|
||||
Exclusions = exclusions,
|
||||
Source = "pom.xml",
|
||||
Locator = sourcePath,
|
||||
VersionSource = versionSource,
|
||||
VersionProperty = versionProperty
|
||||
});
|
||||
}
|
||||
|
||||
return [.. dependencies.OrderBy(d => d.Gav, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static ImmutableArray<JavaExclusion> ParseExclusions(XElement depElement, XNamespace ns)
|
||||
{
|
||||
var exclusionsElement = depElement.Element(ns + "exclusions");
|
||||
if (exclusionsElement is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var exclusions = new List<JavaExclusion>();
|
||||
|
||||
foreach (var excElement in exclusionsElement.Elements(ns + "exclusion"))
|
||||
{
|
||||
var groupId = GetElementValue(excElement, ns, "groupId");
|
||||
var artifactId = GetElementValue(excElement, ns, "artifactId");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(groupId) && !string.IsNullOrWhiteSpace(artifactId))
|
||||
{
|
||||
exclusions.Add(new JavaExclusion(groupId, artifactId));
|
||||
}
|
||||
}
|
||||
|
||||
return [.. exclusions];
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ParseModules(XElement root, XNamespace ns)
|
||||
{
|
||||
var modulesElement = root.Element(ns + "modules");
|
||||
if (modulesElement is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
.. modulesElement.Elements(ns + "module")
|
||||
.Select(e => e.Value?.Trim())
|
||||
.Where(m => !string.IsNullOrWhiteSpace(m))
|
||||
.Cast<string>()
|
||||
.OrderBy(m => m, StringComparer.Ordinal)
|
||||
];
|
||||
}
|
||||
|
||||
private static ImmutableArray<MavenRepository> ParseRepositories(XElement root, XNamespace ns)
|
||||
{
|
||||
var repositoriesElement = root.Element(ns + "repositories");
|
||||
if (repositoriesElement is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var repositories = new List<MavenRepository>();
|
||||
|
||||
foreach (var repoElement in repositoriesElement.Elements(ns + "repository"))
|
||||
{
|
||||
var id = GetElementValue(repoElement, ns, "id");
|
||||
var name = GetElementValue(repoElement, ns, "name");
|
||||
var url = GetElementValue(repoElement, ns, "url");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
repositories.Add(new MavenRepository(id ?? string.Empty, name, url));
|
||||
}
|
||||
}
|
||||
|
||||
return [.. repositories.OrderBy(r => r.Id, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static string? ExtractPropertyName(string value)
|
||||
{
|
||||
var start = value.IndexOf("${", StringComparison.Ordinal);
|
||||
var end = value.IndexOf('}', start + 2);
|
||||
|
||||
if (start >= 0 && end > start)
|
||||
{
|
||||
return value[(start + 2)..end];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed Maven POM file.
|
||||
/// </summary>
|
||||
internal sealed record MavenPom(
|
||||
string SourcePath,
|
||||
string? GroupId,
|
||||
string? ArtifactId,
|
||||
string? Version,
|
||||
string Packaging,
|
||||
string? Name,
|
||||
string? Description,
|
||||
MavenParentRef? Parent,
|
||||
ImmutableDictionary<string, string> Properties,
|
||||
ImmutableArray<JavaLicenseInfo> Licenses,
|
||||
ImmutableArray<JavaDependencyDeclaration> Dependencies,
|
||||
ImmutableArray<JavaDependencyDeclaration> DependencyManagement,
|
||||
ImmutableArray<string> Modules,
|
||||
ImmutableArray<MavenRepository> Repositories)
|
||||
{
|
||||
public static readonly MavenPom Empty = new(
|
||||
string.Empty,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"jar",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this is a parent/aggregator POM.
|
||||
/// </summary>
|
||||
public bool IsParentPom => Packaging.Equals("pom", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this POM has a parent.
|
||||
/// </summary>
|
||||
public bool HasParent => Parent is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate.
|
||||
/// </summary>
|
||||
public string? Gav => GroupId is not null && ArtifactId is not null
|
||||
? Version is not null
|
||||
? $"{GroupId}:{ArtifactId}:{Version}"
|
||||
: $"{GroupId}:{ArtifactId}"
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets BOM imports from dependency management.
|
||||
/// </summary>
|
||||
public IEnumerable<JavaDependencyDeclaration> GetBomImports()
|
||||
=> DependencyManagement.Where(d =>
|
||||
d.Scope?.Equals("import", StringComparison.OrdinalIgnoreCase) == true &&
|
||||
d.Type?.Equals("pom", StringComparison.OrdinalIgnoreCase) == true);
|
||||
|
||||
/// <summary>
|
||||
/// Converts to unified project metadata.
|
||||
/// </summary>
|
||||
public JavaProjectMetadata ToProjectMetadata() => new()
|
||||
{
|
||||
GroupId = GroupId,
|
||||
ArtifactId = ArtifactId,
|
||||
Version = Version,
|
||||
Packaging = Packaging,
|
||||
Parent = Parent is not null
|
||||
? new JavaParentReference
|
||||
{
|
||||
GroupId = Parent.GroupId,
|
||||
ArtifactId = Parent.ArtifactId,
|
||||
Version = Parent.Version,
|
||||
RelativePath = Parent.RelativePath
|
||||
}
|
||||
: null,
|
||||
Properties = Properties,
|
||||
Licenses = Licenses,
|
||||
Dependencies = Dependencies,
|
||||
DependencyManagement = DependencyManagement,
|
||||
SourcePath = SourcePath,
|
||||
BuildSystem = JavaBuildSystem.Maven
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parent POM reference.
|
||||
/// </summary>
|
||||
internal sealed record MavenParentRef(
|
||||
string GroupId,
|
||||
string ArtifactId,
|
||||
string Version,
|
||||
string? RelativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Maven repository.
|
||||
/// </summary>
|
||||
internal sealed record MavenRepository(string Id, string? Name, string Url);
|
||||
@@ -0,0 +1,369 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Osgi;
|
||||
|
||||
/// <summary>
|
||||
/// Parses OSGi bundle metadata from JAR manifest files.
|
||||
/// </summary>
|
||||
internal static partial class OsgiBundleParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses OSGi bundle information from a manifest dictionary.
|
||||
/// </summary>
|
||||
public static OsgiBundleInfo? Parse(IReadOnlyDictionary<string, string> manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
// Check if this is an OSGi bundle
|
||||
if (!manifest.TryGetValue("Bundle-SymbolicName", out var symbolicName) ||
|
||||
string.IsNullOrWhiteSpace(symbolicName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse symbolic name (may include directives like ;singleton:=true)
|
||||
var parsedSymbolicName = ParseSymbolicName(symbolicName);
|
||||
|
||||
var bundleVersion = manifest.GetValueOrDefault("Bundle-Version", "0.0.0");
|
||||
var bundleName = manifest.GetValueOrDefault("Bundle-Name");
|
||||
var bundleVendor = manifest.GetValueOrDefault("Bundle-Vendor");
|
||||
var bundleDescription = manifest.GetValueOrDefault("Bundle-Description");
|
||||
var bundleActivator = manifest.GetValueOrDefault("Bundle-Activator");
|
||||
var bundleCategory = manifest.GetValueOrDefault("Bundle-Category");
|
||||
var bundleLicense = manifest.GetValueOrDefault("Bundle-License");
|
||||
var fragmentHost = manifest.GetValueOrDefault("Fragment-Host");
|
||||
|
||||
// Parse imports and exports
|
||||
var importPackage = ParsePackageList(manifest.GetValueOrDefault("Import-Package"));
|
||||
var exportPackage = ParsePackageList(manifest.GetValueOrDefault("Export-Package"));
|
||||
var requireBundle = ParseRequireBundle(manifest.GetValueOrDefault("Require-Bundle"));
|
||||
var dynamicImport = ParsePackageList(manifest.GetValueOrDefault("DynamicImport-Package"));
|
||||
|
||||
// Parse capabilities and requirements (OSGi R5+)
|
||||
var provideCapability = manifest.GetValueOrDefault("Provide-Capability");
|
||||
var requireCapability = manifest.GetValueOrDefault("Require-Capability");
|
||||
|
||||
return new OsgiBundleInfo(
|
||||
parsedSymbolicName.Name,
|
||||
bundleVersion,
|
||||
bundleName,
|
||||
bundleVendor,
|
||||
bundleDescription,
|
||||
bundleActivator,
|
||||
bundleCategory,
|
||||
bundleLicense,
|
||||
fragmentHost,
|
||||
parsedSymbolicName.IsSingleton,
|
||||
importPackage,
|
||||
exportPackage,
|
||||
requireBundle,
|
||||
dynamicImport,
|
||||
provideCapability,
|
||||
requireCapability);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses manifest content from a string.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> ParseManifest(string manifestContent)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifestContent))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Manifest format uses continuation lines starting with space
|
||||
var lines = manifestContent.Split('\n');
|
||||
string? currentKey = null;
|
||||
var currentValue = new System.Text.StringBuilder();
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.TrimEnd('\r');
|
||||
|
||||
if (line.StartsWith(' ') || line.StartsWith('\t'))
|
||||
{
|
||||
// Continuation line
|
||||
if (currentKey is not null)
|
||||
{
|
||||
currentValue.Append(line.TrimStart());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Save previous entry
|
||||
if (currentKey is not null)
|
||||
{
|
||||
result[currentKey] = currentValue.ToString();
|
||||
}
|
||||
|
||||
// Parse new entry
|
||||
var colonIndex = line.IndexOf(':');
|
||||
if (colonIndex > 0)
|
||||
{
|
||||
currentKey = line[..colonIndex].Trim();
|
||||
currentValue.Clear();
|
||||
currentValue.Append(line[(colonIndex + 1)..].Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
currentKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save last entry
|
||||
if (currentKey is not null)
|
||||
{
|
||||
result[currentKey] = currentValue.ToString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static (string Name, bool IsSingleton) ParseSymbolicName(string symbolicName)
|
||||
{
|
||||
var semicolonIndex = symbolicName.IndexOf(';');
|
||||
if (semicolonIndex < 0)
|
||||
{
|
||||
return (symbolicName.Trim(), false);
|
||||
}
|
||||
|
||||
var name = symbolicName[..semicolonIndex].Trim();
|
||||
var directives = symbolicName[semicolonIndex..];
|
||||
|
||||
var isSingleton = directives.Contains("singleton:=true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return (name, isSingleton);
|
||||
}
|
||||
|
||||
private static ImmutableArray<OsgiPackageSpec> ParsePackageList(string? packageList)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packageList))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var packages = new List<OsgiPackageSpec>();
|
||||
|
||||
// Split by comma, but handle nested quotes and parentheses
|
||||
var entries = SplitPackageEntries(packageList);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var spec = ParsePackageSpec(entry.Trim());
|
||||
if (spec is not null)
|
||||
{
|
||||
packages.Add(spec);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. packages.OrderBy(p => p.PackageName, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static OsgiPackageSpec? ParsePackageSpec(string entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Package may have attributes: com.example.package;version="[1.0,2.0)"
|
||||
var semicolonIndex = entry.IndexOf(';');
|
||||
if (semicolonIndex < 0)
|
||||
{
|
||||
return new OsgiPackageSpec(entry.Trim(), null, null, false);
|
||||
}
|
||||
|
||||
var packageName = entry[..semicolonIndex].Trim();
|
||||
var attributes = entry[semicolonIndex..];
|
||||
|
||||
// Extract version
|
||||
string? version = null;
|
||||
var versionMatch = VersionPattern().Match(attributes);
|
||||
if (versionMatch.Success)
|
||||
{
|
||||
version = versionMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
// Check for resolution:=optional
|
||||
var isOptional = attributes.Contains("resolution:=optional", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Extract uses directive
|
||||
string? uses = null;
|
||||
var usesMatch = UsesPattern().Match(attributes);
|
||||
if (usesMatch.Success)
|
||||
{
|
||||
uses = usesMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
return new OsgiPackageSpec(packageName, version, uses, isOptional);
|
||||
}
|
||||
|
||||
private static ImmutableArray<OsgiBundleRef> ParseRequireBundle(string? requireBundle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requireBundle))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var bundles = new List<OsgiBundleRef>();
|
||||
var entries = SplitPackageEntries(requireBundle);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var semicolonIndex = entry.IndexOf(';');
|
||||
string bundleName;
|
||||
string? bundleVersion = null;
|
||||
bool isOptional = false;
|
||||
|
||||
if (semicolonIndex < 0)
|
||||
{
|
||||
bundleName = entry.Trim();
|
||||
}
|
||||
else
|
||||
{
|
||||
bundleName = entry[..semicolonIndex].Trim();
|
||||
var attributes = entry[semicolonIndex..];
|
||||
|
||||
var versionMatch = BundleVersionPattern().Match(attributes);
|
||||
if (versionMatch.Success)
|
||||
{
|
||||
bundleVersion = versionMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
isOptional = attributes.Contains("resolution:=optional", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bundleName))
|
||||
{
|
||||
bundles.Add(new OsgiBundleRef(bundleName, bundleVersion, isOptional));
|
||||
}
|
||||
}
|
||||
|
||||
return [.. bundles.OrderBy(b => b.SymbolicName, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private static List<string> SplitPackageEntries(string value)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
var depth = 0;
|
||||
var inQuote = false;
|
||||
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
|
||||
if (!inQuote)
|
||||
{
|
||||
if (c == '(' || c == '[') depth++;
|
||||
else if (c == ')' || c == ']') depth--;
|
||||
else if (c == ',' && depth == 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
current.Clear();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current.Append(c);
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
{
|
||||
result.Add(current.ToString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"version\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex VersionPattern();
|
||||
|
||||
[GeneratedRegex(@"uses\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex UsesPattern();
|
||||
|
||||
[GeneratedRegex(@"bundle-version\s*[:=]\s*""([^""]+)""", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BundleVersionPattern();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents OSGi bundle metadata.
|
||||
/// </summary>
|
||||
internal sealed record OsgiBundleInfo(
|
||||
string SymbolicName,
|
||||
string Version,
|
||||
string? Name,
|
||||
string? Vendor,
|
||||
string? Description,
|
||||
string? Activator,
|
||||
string? Category,
|
||||
string? License,
|
||||
string? FragmentHost,
|
||||
bool IsSingleton,
|
||||
ImmutableArray<OsgiPackageSpec> ImportPackage,
|
||||
ImmutableArray<OsgiPackageSpec> ExportPackage,
|
||||
ImmutableArray<OsgiBundleRef> RequireBundle,
|
||||
ImmutableArray<OsgiPackageSpec> DynamicImport,
|
||||
string? ProvideCapability,
|
||||
string? RequireCapability)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if this is a fragment bundle.
|
||||
/// </summary>
|
||||
public bool IsFragment => !string.IsNullOrWhiteSpace(FragmentHost);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Import-Package header as a formatted string.
|
||||
/// </summary>
|
||||
public string GetImportPackageHeader()
|
||||
=> string.Join(",", ImportPackage.Select(p => p.ToHeaderString()));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Export-Package header as a formatted string.
|
||||
/// </summary>
|
||||
public string GetExportPackageHeader()
|
||||
=> string.Join(",", ExportPackage.Select(p => p.ToHeaderString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a package specification in Import-Package or Export-Package.
|
||||
/// </summary>
|
||||
internal sealed record OsgiPackageSpec(
|
||||
string PackageName,
|
||||
string? Version,
|
||||
string? Uses,
|
||||
bool IsOptional)
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts to OSGi header format.
|
||||
/// </summary>
|
||||
public string ToHeaderString()
|
||||
{
|
||||
var result = PackageName;
|
||||
if (Version is not null)
|
||||
{
|
||||
result += $";version=\"{Version}\"";
|
||||
}
|
||||
if (IsOptional)
|
||||
{
|
||||
result += ";resolution:=optional";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Require-Bundle entry.
|
||||
/// </summary>
|
||||
internal sealed record OsgiBundleRef(
|
||||
string SymbolicName,
|
||||
string? BundleVersion,
|
||||
bool IsOptional);
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Java.Internal.BuildMetadata;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.PropertyResolution;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves property placeholders (${property.name}) in Java project metadata.
|
||||
/// Supports Maven-style properties with parent chain resolution.
|
||||
/// </summary>
|
||||
internal sealed partial class JavaPropertyResolver
|
||||
{
|
||||
private const int MaxRecursionDepth = 10;
|
||||
private static readonly Regex PropertyPattern = GetPropertyPattern();
|
||||
|
||||
private readonly ImmutableDictionary<string, string> _baseProperties;
|
||||
private readonly ImmutableArray<ImmutableDictionary<string, string>> _propertyChain;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a property resolver with the given property sources.
|
||||
/// </summary>
|
||||
/// <param name="baseProperties">Properties from the current project.</param>
|
||||
/// <param name="parentProperties">Properties from parent projects, ordered from nearest to root.</param>
|
||||
public JavaPropertyResolver(
|
||||
ImmutableDictionary<string, string>? baseProperties = null,
|
||||
IEnumerable<ImmutableDictionary<string, string>>? parentProperties = null)
|
||||
{
|
||||
_baseProperties = baseProperties ?? ImmutableDictionary<string, string>.Empty;
|
||||
_propertyChain = parentProperties?.ToImmutableArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a resolver from a project metadata and its parent chain.
|
||||
/// </summary>
|
||||
public static JavaPropertyResolver FromProject(JavaProjectMetadata project)
|
||||
{
|
||||
var parentProps = new List<ImmutableDictionary<string, string>>();
|
||||
var current = project.Parent?.ResolvedParent;
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
parentProps.Add(current.Properties);
|
||||
current = current.Parent?.ResolvedParent;
|
||||
}
|
||||
|
||||
return new JavaPropertyResolver(project.Properties, parentProps);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all property placeholders in the given string.
|
||||
/// </summary>
|
||||
/// <param name="value">String containing ${property} placeholders.</param>
|
||||
/// <returns>Resolved string with all placeholders replaced.</returns>
|
||||
public PropertyResolutionResult Resolve(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return PropertyResolutionResult.Empty;
|
||||
}
|
||||
|
||||
if (!value.Contains("${", StringComparison.Ordinal))
|
||||
{
|
||||
return new PropertyResolutionResult(value, true, []);
|
||||
}
|
||||
|
||||
var unresolvedProperties = new List<string>();
|
||||
var resolved = ResolveInternal(value, 0, unresolvedProperties);
|
||||
|
||||
return new PropertyResolutionResult(
|
||||
resolved,
|
||||
unresolvedProperties.Count == 0,
|
||||
unresolvedProperties.ToImmutableArray());
|
||||
}
|
||||
|
||||
private string ResolveInternal(string value, int depth, List<string> unresolved)
|
||||
{
|
||||
if (depth >= MaxRecursionDepth)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return PropertyPattern.Replace(value, match =>
|
||||
{
|
||||
var propertyName = match.Groups[1].Value;
|
||||
|
||||
if (TryGetProperty(propertyName, out var propertyValue))
|
||||
{
|
||||
// Recursively resolve nested properties
|
||||
if (propertyValue.Contains("${", StringComparison.Ordinal))
|
||||
{
|
||||
return ResolveInternal(propertyValue, depth + 1, unresolved);
|
||||
}
|
||||
return propertyValue;
|
||||
}
|
||||
|
||||
// Handle built-in Maven properties
|
||||
if (TryGetBuiltInProperty(propertyName, out var builtInValue))
|
||||
{
|
||||
return builtInValue;
|
||||
}
|
||||
|
||||
unresolved.Add(propertyName);
|
||||
return match.Value; // Keep original placeholder
|
||||
});
|
||||
}
|
||||
|
||||
private bool TryGetProperty(string name, out string value)
|
||||
{
|
||||
// First check base properties
|
||||
if (_baseProperties.TryGetValue(name, out value!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then check parent chain in order
|
||||
foreach (var parentProps in _propertyChain)
|
||||
{
|
||||
if (parentProps.TryGetValue(name, out value!))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetBuiltInProperty(string name, out string value)
|
||||
{
|
||||
// Handle common Maven built-in properties
|
||||
value = name switch
|
||||
{
|
||||
"project.basedir" => ".",
|
||||
"basedir" => ".",
|
||||
"project.build.directory" => "target",
|
||||
"project.build.outputDirectory" => "target/classes",
|
||||
"project.build.testOutputDirectory" => "target/test-classes",
|
||||
"project.build.sourceDirectory" => "src/main/java",
|
||||
"project.build.testSourceDirectory" => "src/test/java",
|
||||
"project.build.resourcesDirectory" => "src/main/resources",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
return !string.IsNullOrEmpty(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a dependency declaration, resolving version and other placeholders.
|
||||
/// </summary>
|
||||
public JavaDependencyDeclaration ResolveDependency(JavaDependencyDeclaration dependency)
|
||||
{
|
||||
var versionResult = Resolve(dependency.Version);
|
||||
|
||||
return dependency with
|
||||
{
|
||||
Version = versionResult.ResolvedValue,
|
||||
VersionSource = versionResult.IsFullyResolved
|
||||
? JavaVersionSource.Property
|
||||
: JavaVersionSource.Unresolved,
|
||||
VersionProperty = dependency.Version?.Contains("${", StringComparison.Ordinal) == true
|
||||
? ExtractPropertyName(dependency.Version)
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractPropertyName(string value)
|
||||
{
|
||||
var match = PropertyPattern.Match(value);
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\$\{([^}]+)\}", RegexOptions.Compiled)]
|
||||
private static partial Regex GetPropertyPattern();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a property resolution operation.
|
||||
/// </summary>
|
||||
internal sealed record PropertyResolutionResult(
|
||||
string ResolvedValue,
|
||||
bool IsFullyResolved,
|
||||
ImmutableArray<string> UnresolvedProperties)
|
||||
{
|
||||
public static readonly PropertyResolutionResult Empty = new(string.Empty, true, []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for constructing property dictionaries from various sources.
|
||||
/// </summary>
|
||||
internal sealed class JavaPropertyBuilder
|
||||
{
|
||||
private readonly Dictionary<string, string> _properties = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a property if it doesn't already exist.
|
||||
/// </summary>
|
||||
public JavaPropertyBuilder Add(string name, string? value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value) && !_properties.ContainsKey(name))
|
||||
{
|
||||
_properties[name] = value;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds project coordinates as properties.
|
||||
/// </summary>
|
||||
public JavaPropertyBuilder AddProjectCoordinates(string? groupId, string? artifactId, string? version)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(groupId))
|
||||
{
|
||||
Add("project.groupId", groupId);
|
||||
Add("groupId", groupId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(artifactId))
|
||||
{
|
||||
Add("project.artifactId", artifactId);
|
||||
Add("artifactId", artifactId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
{
|
||||
Add("project.version", version);
|
||||
Add("version", version);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds parent coordinates as properties.
|
||||
/// </summary>
|
||||
public JavaPropertyBuilder AddParentCoordinates(JavaParentReference? parent)
|
||||
{
|
||||
if (parent is null) return this;
|
||||
|
||||
Add("project.parent.groupId", parent.GroupId);
|
||||
Add("project.parent.artifactId", parent.ArtifactId);
|
||||
Add("project.parent.version", parent.Version);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all properties from an existing dictionary.
|
||||
/// </summary>
|
||||
public JavaPropertyBuilder AddRange(IReadOnlyDictionary<string, string>? properties)
|
||||
{
|
||||
if (properties is null) return this;
|
||||
|
||||
foreach (var (key, value) in properties)
|
||||
{
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an immutable property dictionary.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Build()
|
||||
=> _properties.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Compression;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Java.Internal.Shading;
|
||||
|
||||
/// <summary>
|
||||
/// Detects shaded/shadow JARs that bundle dependencies inside a single artifact.
|
||||
/// </summary>
|
||||
internal static partial class ShadedJarDetector
|
||||
{
|
||||
private static readonly string[] ShadingMarkerFiles =
|
||||
[
|
||||
"META-INF/maven/*/dependency-reduced-pom.xml", // Maven Shade plugin marker
|
||||
"META-INF/maven/*/*/dependency-reduced-pom.xml"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a JAR archive to detect shading.
|
||||
/// </summary>
|
||||
public static ShadingAnalysis Analyze(ZipArchive archive, string jarPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(archive);
|
||||
|
||||
var markers = new List<string>();
|
||||
var embeddedArtifacts = new List<EmbeddedArtifact>();
|
||||
var relocatedPrefixes = new List<string>();
|
||||
|
||||
// Check for multiple pom.properties files (indicates bundled dependencies)
|
||||
var pomPropertiesFiles = archive.Entries
|
||||
.Where(e => e.FullName.EndsWith("pom.properties", StringComparison.OrdinalIgnoreCase) &&
|
||||
e.FullName.Contains("META-INF/maven/", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (pomPropertiesFiles.Count > 1)
|
||||
{
|
||||
markers.Add("multiple-pom-properties");
|
||||
|
||||
// Parse each pom.properties to extract GAV
|
||||
foreach (var entry in pomPropertiesFiles)
|
||||
{
|
||||
var artifact = ParsePomProperties(entry);
|
||||
if (artifact is not null)
|
||||
{
|
||||
embeddedArtifacts.Add(artifact);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dependency-reduced-pom.xml (Maven Shade plugin marker)
|
||||
var hasReducedPom = archive.Entries.Any(e =>
|
||||
e.FullName.Contains("dependency-reduced-pom.xml", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasReducedPom)
|
||||
{
|
||||
markers.Add("dependency-reduced-pom.xml");
|
||||
}
|
||||
|
||||
// Detect relocated packages (common patterns)
|
||||
var relocations = DetectRelocatedPackages(archive);
|
||||
relocatedPrefixes.AddRange(relocations);
|
||||
|
||||
if (relocations.Count > 0)
|
||||
{
|
||||
markers.Add("relocated-packages");
|
||||
}
|
||||
|
||||
// Check for shadow plugin markers
|
||||
var hasShadowMarker = archive.Entries.Any(e =>
|
||||
e.FullName.Contains("shadow/", StringComparison.OrdinalIgnoreCase) &&
|
||||
e.FullName.EndsWith(".class", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasShadowMarker)
|
||||
{
|
||||
markers.Add("gradle-shadow-plugin");
|
||||
}
|
||||
|
||||
// Calculate confidence
|
||||
var confidence = CalculateConfidence(markers, embeddedArtifacts.Count);
|
||||
|
||||
return new ShadingAnalysis(
|
||||
jarPath,
|
||||
confidence >= ShadingConfidence.Medium,
|
||||
confidence,
|
||||
[.. markers],
|
||||
[.. embeddedArtifacts.OrderBy(a => a.Gav, StringComparer.Ordinal)],
|
||||
[.. relocatedPrefixes.Distinct().OrderBy(p => p, StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes a JAR file from disk.
|
||||
/// </summary>
|
||||
public static async Task<ShadingAnalysis> AnalyzeAsync(
|
||||
string jarPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jarPath);
|
||||
|
||||
if (!File.Exists(jarPath))
|
||||
{
|
||||
return ShadingAnalysis.NotShaded(jarPath);
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(jarPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
|
||||
|
||||
return Analyze(archive, jarPath);
|
||||
}
|
||||
|
||||
private static EmbeddedArtifact? ParsePomProperties(ZipArchiveEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? groupId = null;
|
||||
string? artifactId = null;
|
||||
string? version = null;
|
||||
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (line.StartsWith("groupId=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
groupId = line[8..].Trim();
|
||||
}
|
||||
else if (line.StartsWith("artifactId=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
artifactId = line[11..].Trim();
|
||||
}
|
||||
else if (line.StartsWith("version=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
version = line[8..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(groupId) &&
|
||||
!string.IsNullOrWhiteSpace(artifactId) &&
|
||||
!string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return new EmbeddedArtifact(groupId, artifactId, version, entry.FullName);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> DetectRelocatedPackages(ZipArchive archive)
|
||||
{
|
||||
var relocations = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Common relocation prefixes used by shade/shadow plugins
|
||||
var commonRelocatedPrefixes = new[]
|
||||
{
|
||||
"shaded/",
|
||||
"relocated/",
|
||||
"hidden/",
|
||||
"internal/shaded/",
|
||||
"lib/"
|
||||
};
|
||||
|
||||
var classEntries = archive.Entries
|
||||
.Where(e => e.FullName.EndsWith(".class", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(e => e.FullName)
|
||||
.ToList();
|
||||
|
||||
foreach (var prefix in commonRelocatedPrefixes)
|
||||
{
|
||||
if (classEntries.Any(c => c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Extract the full relocation path
|
||||
var relocated = classEntries
|
||||
.Where(c => c.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(c => ExtractPackagePrefix(c))
|
||||
.Where(p => !string.IsNullOrEmpty(p))
|
||||
.Cast<string>()
|
||||
.Distinct()
|
||||
.Take(5); // Limit to avoid noise
|
||||
|
||||
foreach (var r in relocated)
|
||||
{
|
||||
relocations.Add(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect common library packages that are often shaded
|
||||
var shadedLibraryPatterns = new[]
|
||||
{
|
||||
@"^([a-z]+)/com/google/",
|
||||
@"^([a-z]+)/org/apache/",
|
||||
@"^([a-z]+)/io/netty/",
|
||||
@"^([a-z]+)/com/fasterxml/",
|
||||
@"^([a-z]+)/org/slf4j/"
|
||||
};
|
||||
|
||||
foreach (var pattern in shadedLibraryPatterns)
|
||||
{
|
||||
var regex = new Regex(pattern, RegexOptions.IgnoreCase);
|
||||
foreach (var classEntry in classEntries)
|
||||
{
|
||||
var match = regex.Match(classEntry);
|
||||
if (match.Success)
|
||||
{
|
||||
relocations.Add(match.Groups[1].Value + "/");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [.. relocations];
|
||||
}
|
||||
|
||||
private static string? ExtractPackagePrefix(string classPath)
|
||||
{
|
||||
var parts = classPath.Split('/');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
// Return first two path segments as the relocation prefix
|
||||
return $"{parts[0]}/{parts[1]}/";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ShadingConfidence CalculateConfidence(List<string> markers, int embeddedCount)
|
||||
{
|
||||
var score = 0;
|
||||
|
||||
// Strong indicators
|
||||
if (markers.Contains("dependency-reduced-pom.xml")) score += 3;
|
||||
if (markers.Contains("multiple-pom-properties")) score += 2;
|
||||
if (markers.Contains("gradle-shadow-plugin")) score += 3;
|
||||
|
||||
// Moderate indicators
|
||||
if (markers.Contains("relocated-packages")) score += 1;
|
||||
|
||||
// Embedded artifact count
|
||||
if (embeddedCount > 5) score += 2;
|
||||
else if (embeddedCount > 1) score += 1;
|
||||
|
||||
return score switch
|
||||
{
|
||||
>= 4 => ShadingConfidence.High,
|
||||
>= 2 => ShadingConfidence.Medium,
|
||||
>= 1 => ShadingConfidence.Low,
|
||||
_ => ShadingConfidence.None
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of shaded JAR analysis.
|
||||
/// </summary>
|
||||
internal sealed record ShadingAnalysis(
|
||||
string JarPath,
|
||||
bool IsShaded,
|
||||
ShadingConfidence Confidence,
|
||||
ImmutableArray<string> Markers,
|
||||
ImmutableArray<EmbeddedArtifact> EmbeddedArtifacts,
|
||||
ImmutableArray<string> RelocatedPrefixes)
|
||||
{
|
||||
public static ShadingAnalysis NotShaded(string jarPath) => new(
|
||||
jarPath,
|
||||
false,
|
||||
ShadingConfidence.None,
|
||||
[],
|
||||
[],
|
||||
[]);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of embedded artifacts.
|
||||
/// </summary>
|
||||
public int EmbeddedCount => EmbeddedArtifacts.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the embedded artifacts as a comma-separated GAV list.
|
||||
/// </summary>
|
||||
public string GetEmbeddedGavList()
|
||||
=> string.Join(",", EmbeddedArtifacts.Select(a => a.Gav));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an artifact embedded inside a shaded JAR.
|
||||
/// </summary>
|
||||
internal sealed record EmbeddedArtifact(
|
||||
string GroupId,
|
||||
string ArtifactId,
|
||||
string Version,
|
||||
string PomPropertiesPath)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the GAV coordinate.
|
||||
/// </summary>
|
||||
public string Gav => $"{GroupId}:{ArtifactId}:{Version}";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the PURL for this artifact.
|
||||
/// </summary>
|
||||
public string Purl => $"pkg:maven/{GroupId}/{ArtifactId}@{Version}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for shading detection.
|
||||
/// </summary>
|
||||
internal enum ShadingConfidence
|
||||
{
|
||||
None = 0,
|
||||
Low = 1,
|
||||
Medium = 2,
|
||||
High = 3
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
public sealed record BunPackageInventory(
|
||||
string ScanId,
|
||||
string ImageDigest,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
IReadOnlyList<BunPackageArtifact> Packages);
|
||||
|
||||
public sealed record BunPackageArtifact(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string? Version,
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("resolved")] string? Resolved,
|
||||
[property: JsonPropertyName("integrity")] string? Integrity,
|
||||
[property: JsonPropertyName("isDev")] bool? IsDev,
|
||||
[property: JsonPropertyName("isDirect")] bool? IsDirect,
|
||||
[property: JsonPropertyName("isPatched")] bool? IsPatched,
|
||||
[property: JsonPropertyName("provenance")] BunPackageProvenance? Provenance,
|
||||
[property: JsonPropertyName("metadata")] IReadOnlyDictionary<string, string?>? Metadata);
|
||||
|
||||
public sealed record BunPackageProvenance(
|
||||
[property: JsonPropertyName("source")] string? Source,
|
||||
[property: JsonPropertyName("lockfile")] string? Lockfile,
|
||||
[property: JsonPropertyName("locator")] string? Locator);
|
||||
|
||||
public interface IBunPackageInventoryStore
|
||||
{
|
||||
Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken);
|
||||
|
||||
Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NullBunPackageInventoryStore : IBunPackageInventoryStore
|
||||
{
|
||||
public Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
return Task.FromResult<BunPackageInventory?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Catalog;
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class BunPackageInventoryDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string ScanId { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("imageDigest")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? ImageDigest { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("generatedAtUtc")]
|
||||
public DateTime GeneratedAtUtc { get; set; }
|
||||
= DateTime.UtcNow;
|
||||
|
||||
[BsonElement("packages")]
|
||||
public List<BunPackageDocument> Packages { get; set; }
|
||||
= new();
|
||||
}
|
||||
|
||||
[BsonIgnoreExtraElements]
|
||||
public sealed class BunPackageDocument
|
||||
{
|
||||
[BsonElement("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("version")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Version { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("source")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Source { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("resolved")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Resolved { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("integrity")]
|
||||
[BsonIgnoreIfNull]
|
||||
public string? Integrity { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("isDev")]
|
||||
[BsonIgnoreIfNull]
|
||||
public bool? IsDev { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("isDirect")]
|
||||
[BsonIgnoreIfNull]
|
||||
public bool? IsDirect { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("isPatched")]
|
||||
[BsonIgnoreIfNull]
|
||||
public bool? IsPatched { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("provenance")]
|
||||
[BsonIgnoreIfNull]
|
||||
public BunPackageProvenance? Provenance { get; set; }
|
||||
= null;
|
||||
|
||||
[BsonElement("metadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
= null;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Amazon;
|
||||
using Amazon.S3;
|
||||
using Amazon.Runtime;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using Amazon;
|
||||
using Amazon.S3;
|
||||
using Amazon.Runtime;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.Storage.Migrations;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
@@ -62,65 +62,67 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<MongoBootstrapper>();
|
||||
services.TryAddSingleton<ArtifactRepository>();
|
||||
services.TryAddSingleton<ImageRepository>();
|
||||
services.TryAddSingleton<LayerRepository>();
|
||||
services.TryAddSingleton<LinkRepository>();
|
||||
services.TryAddSingleton<JobRepository>();
|
||||
services.TryAddSingleton<LifecycleRuleRepository>();
|
||||
services.TryAddSingleton<RuntimeEventRepository>();
|
||||
services.TryAddSingleton<EntryTraceRepository>();
|
||||
services.TryAddSingleton<RubyPackageInventoryRepository>();
|
||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||
|
||||
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
if (!options.IsRustFsDriver())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS baseUrl must be a valid absolute URI.");
|
||||
}
|
||||
|
||||
client.BaseAddress = baseUri;
|
||||
client.Timeout = options.RustFs.Timeout;
|
||||
|
||||
foreach (var header in options.Headers)
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
|
||||
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
|
||||
}
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
if (!options.IsRustFsDriver())
|
||||
{
|
||||
return new HttpClientHandler();
|
||||
}
|
||||
|
||||
var handler = new HttpClientHandler();
|
||||
if (options.RustFs.AllowInsecureTls)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
return handler;
|
||||
});
|
||||
|
||||
services.TryAddSingleton(CreateAmazonS3Client);
|
||||
services.TryAddSingleton<IArtifactObjectStore>(CreateArtifactObjectStore);
|
||||
services.TryAddSingleton<ArtifactStorageService>();
|
||||
}
|
||||
services.TryAddSingleton<LayerRepository>();
|
||||
services.TryAddSingleton<LinkRepository>();
|
||||
services.TryAddSingleton<JobRepository>();
|
||||
services.TryAddSingleton<LifecycleRuleRepository>();
|
||||
services.TryAddSingleton<RuntimeEventRepository>();
|
||||
services.TryAddSingleton<EntryTraceRepository>();
|
||||
services.TryAddSingleton<RubyPackageInventoryRepository>();
|
||||
services.TryAddSingleton<BunPackageInventoryRepository>();
|
||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
||||
|
||||
services.AddHttpClient(RustFsArtifactObjectStore.HttpClientName)
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
if (!options.IsRustFsDriver())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(options.RustFs.BaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("RustFS baseUrl must be a valid absolute URI.");
|
||||
}
|
||||
|
||||
client.BaseAddress = baseUri;
|
||||
client.Timeout = options.RustFs.Timeout;
|
||||
|
||||
foreach (var header in options.Headers)
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.RustFs.ApiKeyHeader)
|
||||
&& !string.IsNullOrWhiteSpace(options.RustFs.ApiKey))
|
||||
{
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation(options.RustFs.ApiKeyHeader, options.RustFs.ApiKey);
|
||||
}
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
if (!options.IsRustFsDriver())
|
||||
{
|
||||
return new HttpClientHandler();
|
||||
}
|
||||
|
||||
var handler = new HttpClientHandler();
|
||||
if (options.RustFs.AllowInsecureTls)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
return handler;
|
||||
});
|
||||
|
||||
services.TryAddSingleton(CreateAmazonS3Client);
|
||||
services.TryAddSingleton<IArtifactObjectStore>(CreateArtifactObjectStore);
|
||||
services.TryAddSingleton<ArtifactStorageService>();
|
||||
}
|
||||
|
||||
private static IMongoClient CreateMongoClient(IServiceProvider provider)
|
||||
{
|
||||
@@ -149,47 +151,47 @@ public static class ServiceCollectionExtensions
|
||||
return client.GetDatabase(databaseName);
|
||||
}
|
||||
|
||||
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
|
||||
ForcePathStyle = options.ForcePathStyle,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
|
||||
{
|
||||
config.ServiceURL = options.ServiceUrl;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey))
|
||||
{
|
||||
AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken)
|
||||
? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey)
|
||||
: new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken);
|
||||
return new AmazonS3Client(credentials, config);
|
||||
}
|
||||
|
||||
return new AmazonS3Client(config);
|
||||
}
|
||||
|
||||
private static IArtifactObjectStore CreateArtifactObjectStore(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>();
|
||||
var objectStore = options.Value.ObjectStore;
|
||||
|
||||
if (objectStore.IsRustFsDriver())
|
||||
{
|
||||
return new RustFsArtifactObjectStore(
|
||||
provider.GetRequiredService<IHttpClientFactory>(),
|
||||
options,
|
||||
provider.GetRequiredService<ILogger<RustFsArtifactObjectStore>>());
|
||||
}
|
||||
|
||||
return new S3ArtifactObjectStore(
|
||||
provider.GetRequiredService<IAmazonS3>(),
|
||||
options,
|
||||
provider.GetRequiredService<ILogger<S3ArtifactObjectStore>>());
|
||||
}
|
||||
}
|
||||
private static IAmazonS3 CreateAmazonS3Client(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>().Value.ObjectStore;
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
RegionEndpoint = RegionEndpoint.GetBySystemName(options.Region),
|
||||
ForcePathStyle = options.ForcePathStyle,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ServiceUrl))
|
||||
{
|
||||
config.ServiceURL = options.ServiceUrl;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AccessKeyId) && !string.IsNullOrWhiteSpace(options.SecretAccessKey))
|
||||
{
|
||||
AWSCredentials credentials = string.IsNullOrWhiteSpace(options.SessionToken)
|
||||
? new BasicAWSCredentials(options.AccessKeyId, options.SecretAccessKey)
|
||||
: new SessionAWSCredentials(options.AccessKeyId, options.SecretAccessKey, options.SessionToken);
|
||||
return new AmazonS3Client(credentials, config);
|
||||
}
|
||||
|
||||
return new AmazonS3Client(config);
|
||||
}
|
||||
|
||||
private static IArtifactObjectStore CreateArtifactObjectStore(IServiceProvider provider)
|
||||
{
|
||||
var options = provider.GetRequiredService<IOptions<ScannerStorageOptions>>();
|
||||
var objectStore = options.Value.ObjectStore;
|
||||
|
||||
if (objectStore.IsRustFsDriver())
|
||||
{
|
||||
return new RustFsArtifactObjectStore(
|
||||
provider.GetRequiredService<IHttpClientFactory>(),
|
||||
options,
|
||||
provider.GetRequiredService<ILogger<RustFsArtifactObjectStore>>());
|
||||
}
|
||||
|
||||
return new S3ArtifactObjectStore(
|
||||
provider.GetRequiredService<IAmazonS3>(),
|
||||
options,
|
||||
provider.GetRequiredService<ILogger<S3ArtifactObjectStore>>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,15 @@ public sealed class MongoCollectionProvider
|
||||
}
|
||||
|
||||
public IMongoCollection<ArtifactDocument> Artifacts => GetCollection<ArtifactDocument>(ScannerStorageDefaults.Collections.Artifacts);
|
||||
public IMongoCollection<ImageDocument> Images => GetCollection<ImageDocument>(ScannerStorageDefaults.Collections.Images);
|
||||
public IMongoCollection<LayerDocument> Layers => GetCollection<LayerDocument>(ScannerStorageDefaults.Collections.Layers);
|
||||
public IMongoCollection<LinkDocument> Links => GetCollection<LinkDocument>(ScannerStorageDefaults.Collections.Links);
|
||||
public IMongoCollection<JobDocument> Jobs => GetCollection<JobDocument>(ScannerStorageDefaults.Collections.Jobs);
|
||||
public IMongoCollection<LifecycleRuleDocument> LifecycleRules => GetCollection<LifecycleRuleDocument>(ScannerStorageDefaults.Collections.LifecycleRules);
|
||||
public IMongoCollection<RuntimeEventDocument> RuntimeEvents => GetCollection<RuntimeEventDocument>(ScannerStorageDefaults.Collections.RuntimeEvents);
|
||||
public IMongoCollection<EntryTraceDocument> EntryTrace => GetCollection<EntryTraceDocument>(ScannerStorageDefaults.Collections.EntryTrace);
|
||||
public IMongoCollection<RubyPackageInventoryDocument> RubyPackages => GetCollection<RubyPackageInventoryDocument>(ScannerStorageDefaults.Collections.RubyPackages);
|
||||
public IMongoCollection<ImageDocument> Images => GetCollection<ImageDocument>(ScannerStorageDefaults.Collections.Images);
|
||||
public IMongoCollection<LayerDocument> Layers => GetCollection<LayerDocument>(ScannerStorageDefaults.Collections.Layers);
|
||||
public IMongoCollection<LinkDocument> Links => GetCollection<LinkDocument>(ScannerStorageDefaults.Collections.Links);
|
||||
public IMongoCollection<JobDocument> Jobs => GetCollection<JobDocument>(ScannerStorageDefaults.Collections.Jobs);
|
||||
public IMongoCollection<LifecycleRuleDocument> LifecycleRules => GetCollection<LifecycleRuleDocument>(ScannerStorageDefaults.Collections.LifecycleRules);
|
||||
public IMongoCollection<RuntimeEventDocument> RuntimeEvents => GetCollection<RuntimeEventDocument>(ScannerStorageDefaults.Collections.RuntimeEvents);
|
||||
public IMongoCollection<EntryTraceDocument> EntryTrace => GetCollection<EntryTraceDocument>(ScannerStorageDefaults.Collections.EntryTrace);
|
||||
public IMongoCollection<RubyPackageInventoryDocument> RubyPackages => GetCollection<RubyPackageInventoryDocument>(ScannerStorageDefaults.Collections.RubyPackages);
|
||||
public IMongoCollection<BunPackageInventoryDocument> BunPackages => GetCollection<BunPackageInventoryDocument>(ScannerStorageDefaults.Collections.BunPackages);
|
||||
|
||||
private IMongoCollection<TDocument> GetCollection<TDocument>(string name)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
public sealed class BunPackageInventoryRepository
|
||||
{
|
||||
private readonly MongoCollectionProvider _collections;
|
||||
|
||||
public BunPackageInventoryRepository(MongoCollectionProvider collections)
|
||||
{
|
||||
_collections = collections ?? throw new ArgumentNullException(nameof(collections));
|
||||
}
|
||||
|
||||
public async Task<BunPackageInventoryDocument?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
return await _collections.BunPackages
|
||||
.Find(x => x.ScanId == scanId)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(BunPackageInventoryDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
await _collections.BunPackages
|
||||
.ReplaceOneAsync(x => x.ScanId == document.ScanId, document, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace StellaOps.Scanner.Storage;
|
||||
|
||||
public static class ScannerStorageDefaults
|
||||
{
|
||||
public const string DefaultDatabaseName = "scanner";
|
||||
namespace StellaOps.Scanner.Storage;
|
||||
|
||||
public static class ScannerStorageDefaults
|
||||
{
|
||||
public const string DefaultDatabaseName = "scanner";
|
||||
public const string DefaultBucketName = "stellaops";
|
||||
public const string DefaultRootPrefix = "scanner";
|
||||
|
||||
@@ -24,9 +24,10 @@ public static class ScannerStorageDefaults
|
||||
public const string RuntimeEvents = "runtime.events";
|
||||
public const string EntryTrace = "entrytrace";
|
||||
public const string RubyPackages = "ruby.packages";
|
||||
public const string BunPackages = "bun.packages";
|
||||
public const string Migrations = "schema_migrations";
|
||||
}
|
||||
|
||||
|
||||
public static class ObjectPrefixes
|
||||
{
|
||||
public const string Layers = "layers";
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Services;
|
||||
|
||||
public sealed class BunPackageInventoryStore : IBunPackageInventoryStore
|
||||
{
|
||||
private readonly BunPackageInventoryRepository _repository;
|
||||
|
||||
public BunPackageInventoryStore(BunPackageInventoryRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(BunPackageInventory inventory, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inventory);
|
||||
|
||||
var document = new BunPackageInventoryDocument
|
||||
{
|
||||
ScanId = inventory.ScanId,
|
||||
ImageDigest = inventory.ImageDigest,
|
||||
GeneratedAtUtc = inventory.GeneratedAtUtc.UtcDateTime,
|
||||
Packages = inventory.Packages.Select(ToDocument).ToList()
|
||||
};
|
||||
|
||||
await _repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<BunPackageInventory?> GetAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
var document = await _repository.GetAsync(scanId, cancellationToken).ConfigureAwait(false);
|
||||
if (document is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var generatedAt = DateTime.SpecifyKind(document.GeneratedAtUtc, DateTimeKind.Utc);
|
||||
var packages = document.Packages?.Select(FromDocument).ToImmutableArray()
|
||||
?? ImmutableArray<BunPackageArtifact>.Empty;
|
||||
|
||||
return new BunPackageInventory(
|
||||
document.ScanId,
|
||||
document.ImageDigest ?? string.Empty,
|
||||
new DateTimeOffset(generatedAt),
|
||||
packages);
|
||||
}
|
||||
|
||||
private static BunPackageDocument ToDocument(BunPackageArtifact artifact)
|
||||
{
|
||||
var doc = new BunPackageDocument
|
||||
{
|
||||
Id = artifact.Id,
|
||||
Name = artifact.Name,
|
||||
Version = artifact.Version,
|
||||
Source = artifact.Source,
|
||||
Resolved = artifact.Resolved,
|
||||
Integrity = artifact.Integrity,
|
||||
IsDev = artifact.IsDev,
|
||||
IsDirect = artifact.IsDirect,
|
||||
IsPatched = artifact.IsPatched,
|
||||
Provenance = artifact.Provenance,
|
||||
Metadata = artifact.Metadata is null ? null : new Dictionary<string, string?>(artifact.Metadata, StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private static BunPackageArtifact FromDocument(BunPackageDocument document)
|
||||
{
|
||||
IReadOnlyDictionary<string, string?>? metadata = document.Metadata;
|
||||
|
||||
return new BunPackageArtifact(
|
||||
document.Id,
|
||||
document.Name,
|
||||
document.Version,
|
||||
document.Source,
|
||||
document.Resolved,
|
||||
document.Integrity,
|
||||
document.IsDev,
|
||||
document.IsDirect,
|
||||
document.IsPatched,
|
||||
document.Provenance,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.TaskRunner.Client.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Service collection extensions for registering the TaskRunner client.
|
||||
/// </summary>
|
||||
public static class TaskRunnerClientServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the TaskRunner client to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configuration">Configuration.</param>
|
||||
/// <returns>HTTP client builder for further configuration.</returns>
|
||||
public static IHttpClientBuilder AddTaskRunnerClient(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<TaskRunnerClientOptions>(
|
||||
configuration.GetSection(TaskRunnerClientOptions.SectionName));
|
||||
|
||||
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
|
||||
{
|
||||
var options = configuration
|
||||
.GetSection(TaskRunnerClientOptions.SectionName)
|
||||
.Get<TaskRunnerClientOptions>();
|
||||
|
||||
if (options is not null && !string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
{
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options?.UserAgent))
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the TaskRunner client to the service collection with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection.</param>
|
||||
/// <param name="configureOptions">Options configuration action.</param>
|
||||
/// <returns>HTTP client builder for further configuration.</returns>
|
||||
public static IHttpClientBuilder AddTaskRunnerClient(
|
||||
this IServiceCollection services,
|
||||
Action<TaskRunnerClientOptions> configureOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureOptions);
|
||||
|
||||
services.Configure(configureOptions);
|
||||
|
||||
return services.AddHttpClient<ITaskRunnerClient, TaskRunnerClient>((sp, client) =>
|
||||
{
|
||||
var options = new TaskRunnerClientOptions();
|
||||
configureOptions(options);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.BaseUrl))
|
||||
{
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.UserAgent))
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.TryParseAdd(options.UserAgent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using StellaOps.TaskRunner.Client.Models;
|
||||
|
||||
namespace StellaOps.TaskRunner.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for the TaskRunner WebService API.
|
||||
/// </summary>
|
||||
public interface ITaskRunnerClient
|
||||
{
|
||||
#region Pack Runs
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new pack run.
|
||||
/// </summary>
|
||||
/// <param name="request">Run creation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Created run response.</returns>
|
||||
Task<CreatePackRunResponse> CreateRunAsync(
|
||||
CreatePackRunRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current state of a pack run.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Pack run state or null if not found.</returns>
|
||||
Task<PackRunState?> GetRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running pack run.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Cancel response.</returns>
|
||||
Task<CancelRunResponse> CancelRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approvals
|
||||
|
||||
/// <summary>
|
||||
/// Applies an approval decision to a pending approval gate.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="approvalId">Approval gate identifier.</param>
|
||||
/// <param name="request">Decision request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Approval decision response.</returns>
|
||||
Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
|
||||
string runId,
|
||||
string approvalId,
|
||||
ApprovalDecisionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Logs
|
||||
|
||||
/// <summary>
|
||||
/// Streams log entries for a pack run as NDJSON.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of log entries.</returns>
|
||||
IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Artifacts
|
||||
|
||||
/// <summary>
|
||||
/// Lists artifacts produced by a pack run.
|
||||
/// </summary>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Artifact list response.</returns>
|
||||
Task<ArtifactListResponse> ListArtifactsAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Simulation
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a task pack execution without running it.
|
||||
/// </summary>
|
||||
/// <param name="request">Simulation request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Simulation result.</returns>
|
||||
Task<SimulatePackResponse> SimulateAsync(
|
||||
SimulatePackRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metadata
|
||||
|
||||
/// <summary>
|
||||
/// Gets OpenAPI metadata including spec URL, version, and signature.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>OpenAPI metadata.</returns>
|
||||
Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenAPI metadata from /.well-known/openapi endpoint.
|
||||
/// </summary>
|
||||
public sealed record OpenApiMetadata(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("specUrl")] string SpecUrl,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("version")] string Version,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("buildVersion")] string BuildVersion,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("eTag")] string ETag,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("signature")] string Signature);
|
||||
@@ -0,0 +1,230 @@
|
||||
using StellaOps.TaskRunner.Client.Models;
|
||||
|
||||
namespace StellaOps.TaskRunner.Client.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for pack run lifecycle operations.
|
||||
/// </summary>
|
||||
public static class PackRunLifecycleHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Terminal statuses for pack runs.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> TerminalStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"rejected"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a run and waits for it to reach a terminal state.
|
||||
/// </summary>
|
||||
/// <param name="client">TaskRunner client.</param>
|
||||
/// <param name="request">Run creation request.</param>
|
||||
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final pack run state.</returns>
|
||||
public static async Task<PackRunState> CreateAndWaitAsync(
|
||||
ITaskRunnerClient client,
|
||||
CreatePackRunRequest request,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||
|
||||
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return await WaitForCompletionAsync(client, createResponse.RunId, interval, maxWait, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a pack run to reach a terminal state.
|
||||
/// </summary>
|
||||
/// <param name="client">TaskRunner client.</param>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||
/// <param name="timeout">Maximum time to wait (default: 30 minutes).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final pack run state.</returns>
|
||||
public static async Task<PackRunState> WaitForCompletionAsync(
|
||||
ITaskRunnerClient client,
|
||||
string runId,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(maxWait);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
|
||||
if (state is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run '{runId}' not found.");
|
||||
}
|
||||
|
||||
if (TerminalStatuses.Contains(state.Status))
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a pack run to reach a pending approval state.
|
||||
/// </summary>
|
||||
/// <param name="client">TaskRunner client.</param>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="pollInterval">Interval between status checks (default: 2 seconds).</param>
|
||||
/// <param name="timeout">Maximum time to wait (default: 10 minutes).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Pack run state with pending approvals, or null if run completed without approvals.</returns>
|
||||
public static async Task<PackRunState?> WaitForApprovalAsync(
|
||||
ITaskRunnerClient client,
|
||||
string runId,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||
var maxWait = timeout ?? TimeSpan.FromMinutes(10);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(maxWait);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
|
||||
if (state is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run '{runId}' not found.");
|
||||
}
|
||||
|
||||
if (TerminalStatuses.Contains(state.Status))
|
||||
{
|
||||
return null; // Completed without needing approval
|
||||
}
|
||||
|
||||
if (state.PendingApprovals is { Count: > 0 })
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approves all pending approvals for a run.
|
||||
/// </summary>
|
||||
/// <param name="client">TaskRunner client.</param>
|
||||
/// <param name="runId">Run identifier.</param>
|
||||
/// <param name="planHash">Expected plan hash.</param>
|
||||
/// <param name="actorId">Actor applying the approval.</param>
|
||||
/// <param name="summary">Approval summary.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of approvals applied.</returns>
|
||||
public static async Task<int> ApproveAllAsync(
|
||||
ITaskRunnerClient client,
|
||||
string runId,
|
||||
string planHash,
|
||||
string? actorId = null,
|
||||
string? summary = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(planHash);
|
||||
|
||||
var state = await client.GetRunAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||
if (state?.PendingApprovals is null or { Count: 0 })
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
foreach (var approval in state.PendingApprovals)
|
||||
{
|
||||
var request = new ApprovalDecisionRequest("approved", planHash, actorId, summary);
|
||||
await client.ApplyApprovalDecisionAsync(runId, approval.ApprovalId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a run, auto-approves when needed, and waits for completion.
|
||||
/// </summary>
|
||||
/// <param name="client">TaskRunner client.</param>
|
||||
/// <param name="request">Run creation request.</param>
|
||||
/// <param name="actorId">Actor for auto-approval.</param>
|
||||
/// <param name="pollInterval">Interval between status checks.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Final pack run state.</returns>
|
||||
public static async Task<PackRunState> CreateRunAndAutoApproveAsync(
|
||||
ITaskRunnerClient client,
|
||||
CreatePackRunRequest request,
|
||||
string? actorId = null,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var interval = pollInterval ?? TimeSpan.FromSeconds(2);
|
||||
var maxWait = timeout ?? TimeSpan.FromMinutes(30);
|
||||
|
||||
var createResponse = await client.CreateRunAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var runId = createResponse.RunId;
|
||||
var planHash = createResponse.PlanHash;
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(maxWait);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var state = await client.GetRunAsync(runId, cts.Token).ConfigureAwait(false);
|
||||
if (state is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run '{runId}' not found.");
|
||||
}
|
||||
|
||||
if (TerminalStatuses.Contains(state.Status))
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.PendingApprovals is { Count: > 0 })
|
||||
{
|
||||
await ApproveAllAsync(client, runId, planHash, actorId, "Auto-approved by SDK", cts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Delay(interval, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TaskRunner.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new pack run.
|
||||
/// </summary>
|
||||
public sealed record CreatePackRunRequest(
|
||||
[property: JsonPropertyName("packId")] string PackId,
|
||||
[property: JsonPropertyName("packVersion")] string? PackVersion = null,
|
||||
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null,
|
||||
[property: JsonPropertyName("tenantId")] string? TenantId = null,
|
||||
[property: JsonPropertyName("correlationId")] string? CorrelationId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from creating a pack run.
|
||||
/// </summary>
|
||||
public sealed record CreatePackRunResponse(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("planHash")] string PlanHash,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pack run state.
|
||||
/// </summary>
|
||||
public sealed record PackRunState(
|
||||
[property: JsonPropertyName("runId")] string RunId,
|
||||
[property: JsonPropertyName("packId")] string PackId,
|
||||
[property: JsonPropertyName("packVersion")] string PackVersion,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("planHash")] string PlanHash,
|
||||
[property: JsonPropertyName("currentStepId")] string? CurrentStepId,
|
||||
[property: JsonPropertyName("steps")] IReadOnlyList<PackRunStepState> Steps,
|
||||
[property: JsonPropertyName("pendingApprovals")] IReadOnlyList<PendingApproval>? PendingApprovals,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
|
||||
[property: JsonPropertyName("error")] PackRunError? Error);
|
||||
|
||||
/// <summary>
|
||||
/// State of a single step in a pack run.
|
||||
/// </summary>
|
||||
public sealed record PackRunStepState(
|
||||
[property: JsonPropertyName("stepId")] string StepId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("startedAt")] DateTimeOffset? StartedAt,
|
||||
[property: JsonPropertyName("completedAt")] DateTimeOffset? CompletedAt,
|
||||
[property: JsonPropertyName("retryCount")] int RetryCount,
|
||||
[property: JsonPropertyName("outputs")] IReadOnlyDictionary<string, object>? Outputs);
|
||||
|
||||
/// <summary>
|
||||
/// Pending approval gate.
|
||||
/// </summary>
|
||||
public sealed record PendingApproval(
|
||||
[property: JsonPropertyName("approvalId")] string ApprovalId,
|
||||
[property: JsonPropertyName("stepId")] string StepId,
|
||||
[property: JsonPropertyName("message")] string? Message,
|
||||
[property: JsonPropertyName("requiredGrants")] IReadOnlyList<string> RequiredGrants,
|
||||
[property: JsonPropertyName("requestedAt")] DateTimeOffset RequestedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Pack run error information.
|
||||
/// </summary>
|
||||
public sealed record PackRunError(
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("stepId")] string? StepId);
|
||||
|
||||
/// <summary>
|
||||
/// Request to apply an approval decision.
|
||||
/// </summary>
|
||||
public sealed record ApprovalDecisionRequest(
|
||||
[property: JsonPropertyName("decision")] string Decision,
|
||||
[property: JsonPropertyName("planHash")] string PlanHash,
|
||||
[property: JsonPropertyName("actorId")] string? ActorId = null,
|
||||
[property: JsonPropertyName("summary")] string? Summary = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from applying an approval decision.
|
||||
/// </summary>
|
||||
public sealed record ApprovalDecisionResponse(
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("resumed")] bool Resumed);
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate a task pack.
|
||||
/// </summary>
|
||||
public sealed record SimulatePackRequest(
|
||||
[property: JsonPropertyName("manifest")] string Manifest,
|
||||
[property: JsonPropertyName("inputs")] IReadOnlyDictionary<string, object>? Inputs = null);
|
||||
|
||||
/// <summary>
|
||||
/// Simulation result for a task pack.
|
||||
/// </summary>
|
||||
public sealed record SimulatePackResponse(
|
||||
[property: JsonPropertyName("valid")] bool Valid,
|
||||
[property: JsonPropertyName("planHash")] string? PlanHash,
|
||||
[property: JsonPropertyName("steps")] IReadOnlyList<SimulatedStep> Steps,
|
||||
[property: JsonPropertyName("errors")] IReadOnlyList<string>? Errors);
|
||||
|
||||
/// <summary>
|
||||
/// Simulated step in a pack run.
|
||||
/// </summary>
|
||||
public sealed record SimulatedStep(
|
||||
[property: JsonPropertyName("stepId")] string StepId,
|
||||
[property: JsonPropertyName("kind")] string Kind,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("loopInfo")] LoopInfo? LoopInfo,
|
||||
[property: JsonPropertyName("conditionalInfo")] ConditionalInfo? ConditionalInfo,
|
||||
[property: JsonPropertyName("policyInfo")] PolicyInfo? PolicyInfo);
|
||||
|
||||
/// <summary>
|
||||
/// Loop step simulation info.
|
||||
/// </summary>
|
||||
public sealed record LoopInfo(
|
||||
[property: JsonPropertyName("itemsExpression")] string? ItemsExpression,
|
||||
[property: JsonPropertyName("iterator")] string Iterator,
|
||||
[property: JsonPropertyName("maxIterations")] int MaxIterations);
|
||||
|
||||
/// <summary>
|
||||
/// Conditional step simulation info.
|
||||
/// </summary>
|
||||
public sealed record ConditionalInfo(
|
||||
[property: JsonPropertyName("branches")] IReadOnlyList<BranchInfo> Branches,
|
||||
[property: JsonPropertyName("hasElse")] bool HasElse);
|
||||
|
||||
/// <summary>
|
||||
/// Conditional branch info.
|
||||
/// </summary>
|
||||
public sealed record BranchInfo(
|
||||
[property: JsonPropertyName("condition")] string Condition,
|
||||
[property: JsonPropertyName("stepCount")] int StepCount);
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate simulation info.
|
||||
/// </summary>
|
||||
public sealed record PolicyInfo(
|
||||
[property: JsonPropertyName("policyId")] string PolicyId,
|
||||
[property: JsonPropertyName("failureAction")] string FailureAction);
|
||||
|
||||
/// <summary>
|
||||
/// Artifact metadata.
|
||||
/// </summary>
|
||||
public sealed record ArtifactInfo(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("size")] long Size,
|
||||
[property: JsonPropertyName("sha256")] string Sha256,
|
||||
[property: JsonPropertyName("contentType")] string? ContentType,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// List of artifacts.
|
||||
/// </summary>
|
||||
public sealed record ArtifactListResponse(
|
||||
[property: JsonPropertyName("artifacts")] IReadOnlyList<ArtifactInfo> Artifacts);
|
||||
|
||||
/// <summary>
|
||||
/// Run log entry.
|
||||
/// </summary>
|
||||
public sealed record RunLogEntry(
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("level")] string Level,
|
||||
[property: JsonPropertyName("stepId")] string? StepId,
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("traceId")] string? TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Cancel run response.
|
||||
/// </summary>
|
||||
public sealed record CancelRunResponse(
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("message")] string? Message);
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace StellaOps.TaskRunner.Client.Pagination;
|
||||
|
||||
/// <summary>
|
||||
/// Generic paginator for API responses.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of items being paginated.</typeparam>
|
||||
public sealed class Paginator<T>
|
||||
{
|
||||
private readonly Func<int, int, CancellationToken, Task<PagedResponse<T>>> _fetchPage;
|
||||
private readonly int _pageSize;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new paginator.
|
||||
/// </summary>
|
||||
/// <param name="fetchPage">Function to fetch a page (offset, limit, cancellationToken) -> page.</param>
|
||||
/// <param name="pageSize">Number of items per page (default: 50).</param>
|
||||
public Paginator(
|
||||
Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
|
||||
int pageSize = 50)
|
||||
{
|
||||
_fetchPage = fetchPage ?? throw new ArgumentNullException(nameof(fetchPage));
|
||||
_pageSize = pageSize > 0 ? pageSize : throw new ArgumentOutOfRangeException(nameof(pageSize));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iterates through all pages asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of items.</returns>
|
||||
public async IAsyncEnumerable<T> GetAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var page = await _fetchPage(offset, _pageSize, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var item in page.Items)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
if (!page.HasMore || page.Items.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
offset += page.Items.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects all items into a list.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of all items.</returns>
|
||||
public async Task<IReadOnlyList<T>> CollectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var items = new List<T>();
|
||||
|
||||
await foreach (var item in GetAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single page.
|
||||
/// </summary>
|
||||
/// <param name="pageNumber">Page number (1-based).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Single page response.</returns>
|
||||
public Task<PagedResponse<T>> GetPageAsync(int pageNumber, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (pageNumber < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageNumber), "Page number must be >= 1.");
|
||||
}
|
||||
|
||||
var offset = (pageNumber - 1) * _pageSize;
|
||||
return _fetchPage(offset, _pageSize, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated response wrapper.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of items.</typeparam>
|
||||
public sealed record PagedResponse<T>(
|
||||
IReadOnlyList<T> Items,
|
||||
int TotalCount,
|
||||
bool HasMore)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an empty page.
|
||||
/// </summary>
|
||||
public static PagedResponse<T> Empty { get; } = new([], 0, false);
|
||||
|
||||
/// <summary>
|
||||
/// Current page number (1-based) based on offset and page size.
|
||||
/// </summary>
|
||||
public int PageNumber(int offset, int pageSize)
|
||||
=> pageSize > 0 ? (offset / pageSize) + 1 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for creating paginators.
|
||||
/// </summary>
|
||||
public static class PaginatorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a paginator from a fetch function.
|
||||
/// </summary>
|
||||
public static Paginator<T> Paginate<T>(
|
||||
this Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
|
||||
int pageSize = 50)
|
||||
=> new(fetchPage, pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the first N items from an async enumerable.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<T> TakeAsync<T>(
|
||||
this IAsyncEnumerable<T> source,
|
||||
int count,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
if (count <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var taken = 0;
|
||||
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return item;
|
||||
taken++;
|
||||
if (taken >= count)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips the first N items from an async enumerable.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<T> SkipAsync<T>(
|
||||
this IAsyncEnumerable<T> source,
|
||||
int count,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var skipped = 0;
|
||||
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (skipped < count)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Description>SDK client for StellaOps TaskRunner WebService API</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using StellaOps.TaskRunner.Client.Models;
|
||||
|
||||
namespace StellaOps.TaskRunner.Client.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for reading NDJSON streaming logs.
|
||||
/// </summary>
|
||||
public static class StreamingLogReader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Reads log entries from an NDJSON stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">The input stream containing NDJSON log entries.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Async enumerable of log entries.</returns>
|
||||
public static async IAsyncEnumerable<RunLogEntry> ReadAsync(
|
||||
Stream stream,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RunLogEntry? entry;
|
||||
try
|
||||
{
|
||||
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry is not null)
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects all log entries from a stream into a list.
|
||||
/// </summary>
|
||||
/// <param name="stream">The input stream containing NDJSON log entries.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of all log entries.</returns>
|
||||
public static async Task<IReadOnlyList<RunLogEntry>> CollectAsync(
|
||||
Stream stream,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entries = new List<RunLogEntry>();
|
||||
|
||||
await foreach (var entry in ReadAsync(stream, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters log entries by level.
|
||||
/// </summary>
|
||||
/// <param name="entries">Source log entries.</param>
|
||||
/// <param name="levels">Log levels to include (e.g., "error", "warning").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Filtered log entries.</returns>
|
||||
public static async IAsyncEnumerable<RunLogEntry> FilterByLevelAsync(
|
||||
IAsyncEnumerable<RunLogEntry> entries,
|
||||
IReadOnlySet<string> levels,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentNullException.ThrowIfNull(levels);
|
||||
|
||||
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (levels.Contains(entry.Level, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters log entries by step ID.
|
||||
/// </summary>
|
||||
/// <param name="entries">Source log entries.</param>
|
||||
/// <param name="stepId">Step ID to filter by.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Filtered log entries.</returns>
|
||||
public static async IAsyncEnumerable<RunLogEntry> FilterByStepAsync(
|
||||
IAsyncEnumerable<RunLogEntry> entries,
|
||||
string stepId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stepId);
|
||||
|
||||
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (string.Equals(entry.StepId, stepId, StringComparison.Ordinal))
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups log entries by step ID.
|
||||
/// </summary>
|
||||
/// <param name="entries">Source log entries.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Dictionary of step ID to log entries.</returns>
|
||||
public static async Task<IReadOnlyDictionary<string, IReadOnlyList<RunLogEntry>>> GroupByStepAsync(
|
||||
IAsyncEnumerable<RunLogEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
var groups = new Dictionary<string, List<RunLogEntry>>(StringComparer.Ordinal);
|
||||
|
||||
await foreach (var entry in entries.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var key = entry.StepId ?? "(global)";
|
||||
if (!groups.TryGetValue(key, out var list))
|
||||
{
|
||||
list = [];
|
||||
groups[key] = list;
|
||||
}
|
||||
list.Add(entry);
|
||||
}
|
||||
|
||||
return groups.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => (IReadOnlyList<RunLogEntry>)kvp.Value,
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Client.Models;
|
||||
|
||||
namespace StellaOps.TaskRunner.Client;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="ITaskRunnerClient"/>.
|
||||
/// </summary>
|
||||
public sealed class TaskRunnerClient : ITaskRunnerClient
|
||||
{
|
||||
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptionsMonitor<TaskRunnerClientOptions> _options;
|
||||
private readonly ILogger<TaskRunnerClient>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TaskRunnerClient"/> class.
|
||||
/// </summary>
|
||||
public TaskRunnerClient(
|
||||
HttpClient httpClient,
|
||||
IOptionsMonitor<TaskRunnerClientOptions> options,
|
||||
ILogger<TaskRunnerClient>? logger = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
#region Pack Runs
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CreatePackRunResponse> CreateRunAsync(
|
||||
CreatePackRunRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var url = BuildUrl("/runs");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
|
||||
};
|
||||
|
||||
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CreatePackRunResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PackRunState?> GetRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PackRunState>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CancelRunResponse> CancelRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/cancel");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
|
||||
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CancelRunResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approvals
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApprovalDecisionResponse> ApplyApprovalDecisionAsync(
|
||||
string runId,
|
||||
string approvalId,
|
||||
ApprovalDecisionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/approvals/{Uri.EscapeDataString(approvalId)}");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
|
||||
};
|
||||
|
||||
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ApprovalDecisionResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Logs
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<RunLogEntry> StreamLogsAsync(
|
||||
string runId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/logs");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/x-ndjson"));
|
||||
|
||||
// Use longer timeout for streaming
|
||||
var streamingTimeout = TimeSpan.FromSeconds(_options.CurrentValue.StreamingTimeoutSeconds);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(streamingTimeout);
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
httpRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cts.Token).ConfigureAwait(false);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cts.Token).ConfigureAwait(false);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cts.Token).ConfigureAwait(false)) is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
RunLogEntry? entry;
|
||||
try
|
||||
{
|
||||
entry = JsonSerializer.Deserialize<RunLogEntry>(line, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to parse log entry: {Line}", line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry is not null)
|
||||
{
|
||||
yield return entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Artifacts
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ArtifactListResponse> ListArtifactsAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var url = BuildUrl($"/runs/{Uri.EscapeDataString(runId)}/artifacts");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ArtifactListResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? new ArtifactListResponse([]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Simulation
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SimulatePackResponse> SimulateAsync(
|
||||
SimulatePackRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var url = BuildUrl("/simulations");
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = JsonContent.Create(request, JsonMediaType, JsonOptions)
|
||||
};
|
||||
|
||||
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<SimulatePackResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metadata
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpenApiMetadata> GetOpenApiMetadataAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var url = new Uri(new Uri(options.BaseUrl), "/.well-known/openapi");
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
using var response = await SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<OpenApiMetadata>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result ?? throw new InvalidOperationException("Response did not contain expected data.");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private Uri BuildUrl(string path)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var baseUrl = options.BaseUrl.TrimEnd('/');
|
||||
var apiPath = options.ApiPath.TrimEnd('/');
|
||||
return new Uri($"{baseUrl}{apiPath}{path}");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.UserAgent))
|
||||
{
|
||||
request.Headers.UserAgent.TryParseAdd(options.UserAgent);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds));
|
||||
|
||||
return await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.TaskRunner.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the TaskRunner client.
|
||||
/// </summary>
|
||||
public sealed class TaskRunnerClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "TaskRunner:Client";
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for the TaskRunner API (e.g., "https://taskrunner.example.com").
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// API version path prefix (default: "/v1/task-runner").
|
||||
/// </summary>
|
||||
public string ApiPath { get; set; } = "/v1/task-runner";
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for HTTP requests in seconds (default: 30).
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for streaming log requests in seconds (default: 300).
|
||||
/// </summary>
|
||||
public int StreamingTimeoutSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for transient failures (default: 3).
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// User-Agent header value for requests.
|
||||
/// </summary>
|
||||
public string? UserAgent { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Provider for retrieving air-gap sealed mode status.
|
||||
/// </summary>
|
||||
public interface IAirGapStatusProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current sealed mode status of the environment.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Optional tenant ID for multi-tenant environments.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The sealed mode status.</returns>
|
||||
Task<SealedModeStatus> GetStatusAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using StellaOps.TaskRunner.Core.Events;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Audit logger for sealed install enforcement decisions.
|
||||
/// </summary>
|
||||
public interface ISealedInstallAuditLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs an enforcement decision.
|
||||
/// </summary>
|
||||
Task LogEnforcementAsync(
|
||||
TaskPackManifest manifest,
|
||||
SealedInstallEnforcementResult result,
|
||||
string? tenantId = null,
|
||||
string? runId = null,
|
||||
string? actor = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of sealed install audit logger using timeline events.
|
||||
/// </summary>
|
||||
public sealed class SealedInstallAuditLogger : ISealedInstallAuditLogger
|
||||
{
|
||||
private readonly IPackRunTimelineEventEmitter _eventEmitter;
|
||||
|
||||
public SealedInstallAuditLogger(IPackRunTimelineEventEmitter eventEmitter)
|
||||
{
|
||||
_eventEmitter = eventEmitter ?? throw new ArgumentNullException(nameof(eventEmitter));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task LogEnforcementAsync(
|
||||
TaskPackManifest manifest,
|
||||
SealedInstallEnforcementResult result,
|
||||
string? tenantId = null,
|
||||
string? runId = null,
|
||||
string? actor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var effectiveTenantId = tenantId ?? "default";
|
||||
var effectiveRunId = runId ?? Guid.NewGuid().ToString("n");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var eventType = result.Allowed
|
||||
? PackRunEventTypes.SealedInstallAllowed
|
||||
: PackRunEventTypes.SealedInstallDenied;
|
||||
|
||||
var severity = result.Allowed
|
||||
? PackRunEventSeverity.Info
|
||||
: PackRunEventSeverity.Warning;
|
||||
|
||||
var attributes = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["pack_name"] = manifest.Metadata.Name,
|
||||
["pack_version"] = manifest.Metadata.Version,
|
||||
["decision"] = result.Allowed ? "allowed" : "denied",
|
||||
["sealed_install_required"] = manifest.Spec.SealedInstall.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
|
||||
{
|
||||
attributes["error_code"] = result.ErrorCode;
|
||||
}
|
||||
|
||||
object payload;
|
||||
if (result.Allowed)
|
||||
{
|
||||
payload = new
|
||||
{
|
||||
event_type = "sealed_install_enforcement",
|
||||
pack_id = manifest.Metadata.Name,
|
||||
pack_version = manifest.Metadata.Version,
|
||||
decision = "allowed",
|
||||
reason = result.Message
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
payload = new
|
||||
{
|
||||
event_type = "sealed_install_enforcement",
|
||||
pack_id = manifest.Metadata.Name,
|
||||
pack_version = manifest.Metadata.Version,
|
||||
decision = "denied",
|
||||
reason = result.ErrorCode,
|
||||
message = result.Message,
|
||||
violation = result.Violation is not null
|
||||
? new
|
||||
{
|
||||
required_sealed = result.Violation.RequiredSealed,
|
||||
actual_sealed = result.Violation.ActualSealed,
|
||||
recommendation = result.Violation.Recommendation
|
||||
}
|
||||
: null,
|
||||
requirement_violations = result.RequirementViolations?.Select(v => new
|
||||
{
|
||||
requirement = v.Requirement,
|
||||
expected = v.Expected,
|
||||
actual = v.Actual,
|
||||
message = v.Message
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
var timelineEvent = PackRunTimelineEvent.Create(
|
||||
tenantId: effectiveTenantId,
|
||||
eventType: eventType,
|
||||
source: "StellaOps.TaskRunner.SealedInstallEnforcer",
|
||||
occurredAt: now,
|
||||
runId: effectiveRunId,
|
||||
actor: actor,
|
||||
severity: severity,
|
||||
attributes: attributes,
|
||||
payload: payload);
|
||||
|
||||
await _eventEmitter.EmitAsync(timelineEvent, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces sealed install requirements for task packs.
|
||||
/// Per sealed-install-enforcement.md contract.
|
||||
/// </summary>
|
||||
public interface ISealedInstallEnforcer
|
||||
{
|
||||
/// <summary>
|
||||
/// Enforces sealed install requirements for a task pack.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The task pack manifest.</param>
|
||||
/// <param name="tenantId">Optional tenant ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Enforcement result indicating whether execution is allowed.</returns>
|
||||
Task<SealedInstallEnforcementResult> EnforceAsync(
|
||||
TaskPackManifest manifest,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Result of sealed install enforcement check.
|
||||
/// Per sealed-install-enforcement.md contract.
|
||||
/// </summary>
|
||||
public sealed record SealedInstallEnforcementResult(
|
||||
/// <summary>Whether execution is allowed.</summary>
|
||||
bool Allowed,
|
||||
|
||||
/// <summary>Error code if denied.</summary>
|
||||
string? ErrorCode,
|
||||
|
||||
/// <summary>Human-readable message.</summary>
|
||||
string Message,
|
||||
|
||||
/// <summary>Detailed violation information.</summary>
|
||||
SealedInstallViolation? Violation,
|
||||
|
||||
/// <summary>Requirement violations if any.</summary>
|
||||
IReadOnlyList<RequirementViolation>? RequirementViolations)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an allowed result.
|
||||
/// </summary>
|
||||
public static SealedInstallEnforcementResult CreateAllowed(string message)
|
||||
=> new(true, null, message, null, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a denied result.
|
||||
/// </summary>
|
||||
public static SealedInstallEnforcementResult CreateDenied(
|
||||
string errorCode,
|
||||
string message,
|
||||
SealedInstallViolation? violation = null,
|
||||
IReadOnlyList<RequirementViolation>? requirementViolations = null)
|
||||
=> new(false, errorCode, message, violation, requirementViolations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details about a sealed install violation.
|
||||
/// </summary>
|
||||
public sealed record SealedInstallViolation(
|
||||
/// <summary>Pack ID that requires sealed install.</summary>
|
||||
string PackId,
|
||||
|
||||
/// <summary>Pack version.</summary>
|
||||
string? PackVersion,
|
||||
|
||||
/// <summary>Whether pack requires sealed install.</summary>
|
||||
bool RequiredSealed,
|
||||
|
||||
/// <summary>Actual sealed status of environment.</summary>
|
||||
bool ActualSealed,
|
||||
|
||||
/// <summary>Recommendation for resolving the violation.</summary>
|
||||
string Recommendation);
|
||||
|
||||
/// <summary>
|
||||
/// Details about a requirement violation.
|
||||
/// </summary>
|
||||
public sealed record RequirementViolation(
|
||||
/// <summary>Name of the requirement that was violated.</summary>
|
||||
string Requirement,
|
||||
|
||||
/// <summary>Expected value.</summary>
|
||||
string Expected,
|
||||
|
||||
/// <summary>Actual value.</summary>
|
||||
string Actual,
|
||||
|
||||
/// <summary>Human-readable message describing the violation.</summary>
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for sealed install enforcement.
|
||||
/// </summary>
|
||||
public static class SealedInstallErrorCodes
|
||||
{
|
||||
/// <summary>Pack requires sealed but environment is not sealed.</summary>
|
||||
public const string SealedInstallViolation = "SEALED_INSTALL_VIOLATION";
|
||||
|
||||
/// <summary>Sealed requirements not met.</summary>
|
||||
public const string SealedRequirementsViolation = "SEALED_REQUIREMENTS_VIOLATION";
|
||||
|
||||
/// <summary>Bundle version below minimum required.</summary>
|
||||
public const string BundleVersionViolation = "BUNDLE_VERSION_VIOLATION";
|
||||
|
||||
/// <summary>Advisory data too stale.</summary>
|
||||
public const string AdvisoryStalenessViolation = "ADVISORY_STALENESS_VIOLATION";
|
||||
|
||||
/// <summary>Time anchor missing or invalid.</summary>
|
||||
public const string TimeAnchorViolation = "TIME_ANCHOR_VIOLATION";
|
||||
|
||||
/// <summary>Bundle signature verification failed.</summary>
|
||||
public const string SignatureVerificationViolation = "SIGNATURE_VERIFICATION_VIOLATION";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI exit codes for sealed install enforcement.
|
||||
/// </summary>
|
||||
public static class SealedInstallExitCodes
|
||||
{
|
||||
/// <summary>Pack requires sealed but environment is not.</summary>
|
||||
public const int SealedInstallViolation = 40;
|
||||
|
||||
/// <summary>Bundle version below minimum.</summary>
|
||||
public const int BundleVersionViolation = 41;
|
||||
|
||||
/// <summary>Advisory data too stale.</summary>
|
||||
public const int AdvisoryStalenessViolation = 42;
|
||||
|
||||
/// <summary>Time anchor missing or invalid.</summary>
|
||||
public const int TimeAnchorViolation = 43;
|
||||
|
||||
/// <summary>Bundle signature verification failed.</summary>
|
||||
public const int SignatureVerificationViolation = 44;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces sealed install requirements for task packs.
|
||||
/// Per sealed-install-enforcement.md contract.
|
||||
/// </summary>
|
||||
public sealed class SealedInstallEnforcer : ISealedInstallEnforcer
|
||||
{
|
||||
private readonly IAirGapStatusProvider _statusProvider;
|
||||
private readonly IOptions<SealedInstallEnforcementOptions> _options;
|
||||
private readonly ILogger<SealedInstallEnforcer> _logger;
|
||||
|
||||
public SealedInstallEnforcer(
|
||||
IAirGapStatusProvider statusProvider,
|
||||
IOptions<SealedInstallEnforcementOptions> options,
|
||||
ILogger<SealedInstallEnforcer> logger)
|
||||
{
|
||||
_statusProvider = statusProvider ?? throw new ArgumentNullException(nameof(statusProvider));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SealedInstallEnforcementResult> EnforceAsync(
|
||||
TaskPackManifest manifest,
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var options = _options.Value;
|
||||
|
||||
// Check if enforcement is enabled
|
||||
if (!options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Sealed install enforcement is disabled.");
|
||||
return SealedInstallEnforcementResult.CreateAllowed("Enforcement disabled");
|
||||
}
|
||||
|
||||
// Check for development bypass
|
||||
if (options.BypassForDevelopment && IsDevelopmentEnvironment())
|
||||
{
|
||||
_logger.LogWarning("Sealed install enforcement bypassed for development environment.");
|
||||
return SealedInstallEnforcementResult.CreateAllowed("Development bypass active");
|
||||
}
|
||||
|
||||
// If pack doesn't require sealed install, allow
|
||||
if (!manifest.Spec.SealedInstall)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Pack {PackName} v{PackVersion} does not require sealed install.",
|
||||
manifest.Metadata.Name,
|
||||
manifest.Metadata.Version);
|
||||
|
||||
return SealedInstallEnforcementResult.CreateAllowed("Pack does not require sealed install");
|
||||
}
|
||||
|
||||
// Get environment sealed status
|
||||
SealedModeStatus status;
|
||||
try
|
||||
{
|
||||
status = await _statusProvider.GetStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get air-gap status. Denying sealed install pack.");
|
||||
|
||||
return SealedInstallEnforcementResult.CreateDenied(
|
||||
SealedInstallErrorCodes.SealedInstallViolation,
|
||||
"Failed to verify sealed mode status",
|
||||
new SealedInstallViolation(
|
||||
manifest.Metadata.Name,
|
||||
manifest.Metadata.Version,
|
||||
RequiredSealed: true,
|
||||
ActualSealed: false,
|
||||
Recommendation: "Ensure the AirGap controller is accessible: stella airgap status"));
|
||||
}
|
||||
|
||||
// Core check: environment must be sealed
|
||||
if (!status.Sealed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sealed install violation: Pack {PackName} v{PackVersion} requires sealed environment but environment is {Mode}.",
|
||||
manifest.Metadata.Name,
|
||||
manifest.Metadata.Version,
|
||||
status.Mode);
|
||||
|
||||
return SealedInstallEnforcementResult.CreateDenied(
|
||||
SealedInstallErrorCodes.SealedInstallViolation,
|
||||
"Pack requires sealed environment but environment is not sealed",
|
||||
new SealedInstallViolation(
|
||||
manifest.Metadata.Name,
|
||||
manifest.Metadata.Version,
|
||||
RequiredSealed: true,
|
||||
ActualSealed: false,
|
||||
Recommendation: "Activate sealed mode with: stella airgap seal"));
|
||||
}
|
||||
|
||||
// Check sealed requirements if specified
|
||||
var requirements = manifest.Spec.SealedRequirements ?? SealedRequirements.Default;
|
||||
var violations = ValidateRequirements(requirements, status, options);
|
||||
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sealed requirements violation for pack {PackName} v{PackVersion}: {ViolationCount} requirement(s) not met.",
|
||||
manifest.Metadata.Name,
|
||||
manifest.Metadata.Version,
|
||||
violations.Count);
|
||||
|
||||
return SealedInstallEnforcementResult.CreateDenied(
|
||||
SealedInstallErrorCodes.SealedRequirementsViolation,
|
||||
"Sealed requirements not met",
|
||||
violation: null,
|
||||
requirementViolations: violations);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sealed install requirements satisfied for pack {PackName} v{PackVersion}.",
|
||||
manifest.Metadata.Name,
|
||||
manifest.Metadata.Version);
|
||||
|
||||
return SealedInstallEnforcementResult.CreateAllowed("Sealed install requirements satisfied");
|
||||
}
|
||||
|
||||
private List<RequirementViolation> ValidateRequirements(
|
||||
SealedRequirements requirements,
|
||||
SealedModeStatus status,
|
||||
SealedInstallEnforcementOptions options)
|
||||
{
|
||||
var violations = new List<RequirementViolation>();
|
||||
|
||||
// Bundle version check
|
||||
if (!string.IsNullOrWhiteSpace(requirements.MinBundleVersion) &&
|
||||
!string.IsNullOrWhiteSpace(status.BundleVersion))
|
||||
{
|
||||
if (!IsVersionSatisfied(status.BundleVersion, requirements.MinBundleVersion))
|
||||
{
|
||||
violations.Add(new RequirementViolation(
|
||||
Requirement: "min_bundle_version",
|
||||
Expected: requirements.MinBundleVersion,
|
||||
Actual: status.BundleVersion,
|
||||
Message: $"Bundle version {status.BundleVersion} < required {requirements.MinBundleVersion}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Advisory staleness check
|
||||
var effectiveStaleness = status.AdvisoryStalenessHours;
|
||||
var maxStaleness = requirements.MaxAdvisoryStalenessHours;
|
||||
|
||||
// Apply grace period if configured
|
||||
if (options.StalenessGracePeriodHours > 0)
|
||||
{
|
||||
maxStaleness += options.StalenessGracePeriodHours;
|
||||
}
|
||||
|
||||
if (effectiveStaleness > maxStaleness)
|
||||
{
|
||||
if (options.DenyOnStaleness)
|
||||
{
|
||||
violations.Add(new RequirementViolation(
|
||||
Requirement: "max_advisory_staleness_hours",
|
||||
Expected: requirements.MaxAdvisoryStalenessHours.ToString(),
|
||||
Actual: effectiveStaleness.ToString(),
|
||||
Message: $"Advisory data is {effectiveStaleness}h old, max allowed is {requirements.MaxAdvisoryStalenessHours}h"));
|
||||
}
|
||||
else if (effectiveStaleness > options.StalenessWarningThresholdHours)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Advisory data is {Staleness}h old, approaching max allowed {MaxStaleness}h.",
|
||||
effectiveStaleness,
|
||||
requirements.MaxAdvisoryStalenessHours);
|
||||
}
|
||||
}
|
||||
|
||||
// Time anchor check
|
||||
if (requirements.RequireTimeAnchor)
|
||||
{
|
||||
if (status.TimeAnchor is null)
|
||||
{
|
||||
violations.Add(new RequirementViolation(
|
||||
Requirement: "require_time_anchor",
|
||||
Expected: "valid time anchor",
|
||||
Actual: "missing",
|
||||
Message: "Valid time anchor required but not present"));
|
||||
}
|
||||
else if (!status.TimeAnchor.Valid)
|
||||
{
|
||||
violations.Add(new RequirementViolation(
|
||||
Requirement: "require_time_anchor",
|
||||
Expected: "valid time anchor",
|
||||
Actual: "invalid",
|
||||
Message: "Time anchor present but invalid or expired"));
|
||||
}
|
||||
else if (status.TimeAnchor.ExpiresAt.HasValue &&
|
||||
status.TimeAnchor.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
{
|
||||
violations.Add(new RequirementViolation(
|
||||
Requirement: "require_time_anchor",
|
||||
Expected: "non-expired time anchor",
|
||||
Actual: $"expired at {status.TimeAnchor.ExpiresAt.Value:O}",
|
||||
Message: "Time anchor has expired"));
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
private static bool IsVersionSatisfied(string actual, string required)
|
||||
{
|
||||
// Try semantic version comparison
|
||||
if (Version.TryParse(NormalizeVersion(actual), out var actualVersion) &&
|
||||
Version.TryParse(NormalizeVersion(required), out var requiredVersion))
|
||||
{
|
||||
return actualVersion >= requiredVersion;
|
||||
}
|
||||
|
||||
// Fall back to string comparison
|
||||
return string.Compare(actual, required, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static string NormalizeVersion(string version)
|
||||
{
|
||||
// Strip common prefixes like 'v' and suffixes like '-beta'
|
||||
var normalized = version.TrimStart('v', 'V');
|
||||
var dashIndex = normalized.IndexOf('-', StringComparison.Ordinal);
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
normalized = normalized[..dashIndex];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static bool IsDevelopmentEnvironment()
|
||||
{
|
||||
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ??
|
||||
Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
|
||||
return string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for sealed install enforcement.
|
||||
/// </summary>
|
||||
public sealed class SealedInstallEnforcementOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether enforcement is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Grace period for advisory staleness in hours.
|
||||
/// </summary>
|
||||
public int StalenessGracePeriodHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Warning threshold for staleness in hours.
|
||||
/// </summary>
|
||||
public int StalenessWarningThresholdHours { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to deny on staleness violation (false = warn only).
|
||||
/// </summary>
|
||||
public bool DenyOnStaleness { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use heuristic detection when AirGap controller is unavailable.
|
||||
/// </summary>
|
||||
public bool UseHeuristicDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Heuristic score threshold to consider environment sealed.
|
||||
/// </summary>
|
||||
public double HeuristicThreshold { get; set; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Bypass enforcement in development environments (DANGEROUS).
|
||||
/// </summary>
|
||||
public bool BypassForDevelopment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Log all enforcement decisions.
|
||||
/// </summary>
|
||||
public bool LogAllDecisions { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Audit retention in days.
|
||||
/// </summary>
|
||||
public int AuditRetentionDays { get; set; } = 365;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the sealed mode status of the air-gap environment.
|
||||
/// Per sealed-install-enforcement.md contract.
|
||||
/// </summary>
|
||||
public sealed record SealedModeStatus(
|
||||
/// <summary>Whether the environment is currently sealed.</summary>
|
||||
bool Sealed,
|
||||
|
||||
/// <summary>Current mode (sealed, unsealed, transitioning).</summary>
|
||||
string Mode,
|
||||
|
||||
/// <summary>When the environment was sealed.</summary>
|
||||
DateTimeOffset? SealedAt,
|
||||
|
||||
/// <summary>Identity that sealed the environment.</summary>
|
||||
string? SealedBy,
|
||||
|
||||
/// <summary>Air-gap bundle version currently installed.</summary>
|
||||
string? BundleVersion,
|
||||
|
||||
/// <summary>Digest of the bundle.</summary>
|
||||
string? BundleDigest,
|
||||
|
||||
/// <summary>When advisories were last updated.</summary>
|
||||
DateTimeOffset? LastAdvisoryUpdate,
|
||||
|
||||
/// <summary>Hours since last advisory update.</summary>
|
||||
int AdvisoryStalenessHours,
|
||||
|
||||
/// <summary>Time anchor information.</summary>
|
||||
TimeAnchorInfo? TimeAnchor,
|
||||
|
||||
/// <summary>Whether egress is blocked.</summary>
|
||||
bool EgressBlocked,
|
||||
|
||||
/// <summary>Network policy in effect.</summary>
|
||||
string? NetworkPolicy)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an unsealed status (environment not in air-gap mode).
|
||||
/// </summary>
|
||||
public static SealedModeStatus Unsealed() => new(
|
||||
Sealed: false,
|
||||
Mode: "unsealed",
|
||||
SealedAt: null,
|
||||
SealedBy: null,
|
||||
BundleVersion: null,
|
||||
BundleDigest: null,
|
||||
LastAdvisoryUpdate: null,
|
||||
AdvisoryStalenessHours: 0,
|
||||
TimeAnchor: null,
|
||||
EgressBlocked: false,
|
||||
NetworkPolicy: null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a status indicating the provider is unavailable.
|
||||
/// </summary>
|
||||
public static SealedModeStatus Unavailable() => new(
|
||||
Sealed: false,
|
||||
Mode: "unavailable",
|
||||
SealedAt: null,
|
||||
SealedBy: null,
|
||||
BundleVersion: null,
|
||||
BundleDigest: null,
|
||||
LastAdvisoryUpdate: null,
|
||||
AdvisoryStalenessHours: 0,
|
||||
TimeAnchor: null,
|
||||
EgressBlocked: false,
|
||||
NetworkPolicy: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor information for sealed environments.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorInfo(
|
||||
/// <summary>The anchor timestamp.</summary>
|
||||
DateTimeOffset Timestamp,
|
||||
|
||||
/// <summary>Signature of the time anchor.</summary>
|
||||
string? Signature,
|
||||
|
||||
/// <summary>Whether the time anchor is valid.</summary>
|
||||
bool Valid,
|
||||
|
||||
/// <summary>When the time anchor expires.</summary>
|
||||
DateTimeOffset? ExpiresAt);
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// Sealed install requirements specified in a task pack manifest.
|
||||
/// Per sealed-install-enforcement.md contract.
|
||||
/// </summary>
|
||||
public sealed record SealedRequirements(
|
||||
/// <summary>Minimum air-gap bundle version required.</summary>
|
||||
[property: JsonPropertyName("min_bundle_version")]
|
||||
string? MinBundleVersion,
|
||||
|
||||
/// <summary>Maximum age of advisory data in hours (default: 168).</summary>
|
||||
[property: JsonPropertyName("max_advisory_staleness_hours")]
|
||||
int MaxAdvisoryStalenessHours,
|
||||
|
||||
/// <summary>Whether a valid time anchor is required (default: true).</summary>
|
||||
[property: JsonPropertyName("require_time_anchor")]
|
||||
bool RequireTimeAnchor,
|
||||
|
||||
/// <summary>Maximum allowed offline duration in hours (default: 720).</summary>
|
||||
[property: JsonPropertyName("allowed_offline_duration_hours")]
|
||||
int AllowedOfflineDurationHours,
|
||||
|
||||
/// <summary>Whether bundle signature verification is required (default: true).</summary>
|
||||
[property: JsonPropertyName("require_signature_verification")]
|
||||
bool RequireSignatureVerification)
|
||||
{
|
||||
/// <summary>
|
||||
/// Default sealed requirements.
|
||||
/// </summary>
|
||||
public static SealedRequirements Default => new(
|
||||
MinBundleVersion: null,
|
||||
MaxAdvisoryStalenessHours: 168,
|
||||
RequireTimeAnchor: true,
|
||||
AllowedOfflineDurationHours: 720,
|
||||
RequireSignatureVerification: true);
|
||||
}
|
||||
@@ -301,6 +301,18 @@ public static class PackRunEventTypes
|
||||
/// <summary>Policy gate evaluated.</summary>
|
||||
public const string PolicyEvaluated = "pack.policy.evaluated";
|
||||
|
||||
/// <summary>Sealed install enforcement performed.</summary>
|
||||
public const string SealedInstallEnforcement = "pack.sealed_install.enforcement";
|
||||
|
||||
/// <summary>Sealed install enforcement denied execution.</summary>
|
||||
public const string SealedInstallDenied = "pack.sealed_install.denied";
|
||||
|
||||
/// <summary>Sealed install enforcement allowed execution.</summary>
|
||||
public const string SealedInstallAllowed = "pack.sealed_install.allowed";
|
||||
|
||||
/// <summary>Sealed install requirements warning.</summary>
|
||||
public const string SealedInstallWarning = "pack.sealed_install.warning";
|
||||
|
||||
/// <summary>Checks if the event type is a pack run event.</summary>
|
||||
public static bool IsPackRunEvent(string eventType) =>
|
||||
eventType.StartsWith(Prefix, StringComparison.Ordinal);
|
||||
|
||||
@@ -2,9 +2,9 @@ using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.Execution;
|
||||
|
||||
internal static class TaskRunnerTelemetry
|
||||
public static class TaskRunnerTelemetry
|
||||
{
|
||||
internal const string MeterName = "stellaops.taskrunner";
|
||||
public const string MeterName = "stellaops.taskrunner";
|
||||
|
||||
internal static readonly Meter Meter = new(MeterName);
|
||||
internal static readonly Histogram<double> StepDurationMs =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
namespace StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
@@ -82,6 +83,18 @@ public sealed class TaskPackSpec
|
||||
|
||||
[JsonPropertyName("slo")]
|
||||
public TaskPackSlo? Slo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this pack requires a sealed (air-gapped) environment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sealedInstall")]
|
||||
public bool SealedInstall { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Specific requirements for sealed install mode.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sealedRequirements")]
|
||||
public SealedRequirements? SealedRequirements { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackInput
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.Core.AirGap;
|
||||
|
||||
namespace StellaOps.TaskRunner.Infrastructure.AirGap;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation for retrieving air-gap status from the AirGap controller.
|
||||
/// </summary>
|
||||
public sealed class HttpAirGapStatusProvider : IAirGapStatusProvider
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<AirGapStatusProviderOptions> _options;
|
||||
private readonly ILogger<HttpAirGapStatusProvider> _logger;
|
||||
|
||||
public HttpAirGapStatusProvider(
|
||||
HttpClient httpClient,
|
||||
IOptions<AirGapStatusProviderOptions> options,
|
||||
ILogger<HttpAirGapStatusProvider> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SealedModeStatus> GetStatusAsync(
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = _options.Value;
|
||||
var url = string.IsNullOrWhiteSpace(tenantId)
|
||||
? options.StatusEndpoint
|
||||
: $"{options.StatusEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetFromJsonAsync<AirGapStatusDto>(
|
||||
url,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response is null)
|
||||
{
|
||||
_logger.LogWarning("AirGap controller returned null response.");
|
||||
return SealedModeStatus.Unavailable();
|
||||
}
|
||||
|
||||
return MapToSealedModeStatus(response);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to connect to AirGap controller at {Url}.", url);
|
||||
|
||||
if (options.UseHeuristicFallback)
|
||||
{
|
||||
return await GetStatusFromHeuristicsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return SealedModeStatus.Unavailable();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error getting air-gap status.");
|
||||
return SealedModeStatus.Unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private static SealedModeStatus MapToSealedModeStatus(AirGapStatusDto dto)
|
||||
{
|
||||
TimeAnchorInfo? timeAnchor = null;
|
||||
if (dto.TimeAnchor is not null)
|
||||
{
|
||||
timeAnchor = new TimeAnchorInfo(
|
||||
dto.TimeAnchor.Timestamp,
|
||||
dto.TimeAnchor.Signature,
|
||||
dto.TimeAnchor.Valid,
|
||||
dto.TimeAnchor.ExpiresAt);
|
||||
}
|
||||
|
||||
return new SealedModeStatus(
|
||||
Sealed: dto.Sealed,
|
||||
Mode: dto.Sealed ? "sealed" : "unsealed",
|
||||
SealedAt: dto.SealedAt,
|
||||
SealedBy: dto.SealedBy,
|
||||
BundleVersion: dto.BundleVersion,
|
||||
BundleDigest: dto.BundleDigest,
|
||||
LastAdvisoryUpdate: dto.LastAdvisoryUpdate,
|
||||
AdvisoryStalenessHours: dto.AdvisoryStalenessHours,
|
||||
TimeAnchor: timeAnchor,
|
||||
EgressBlocked: dto.EgressBlocked,
|
||||
NetworkPolicy: dto.NetworkPolicy);
|
||||
}
|
||||
|
||||
private async Task<SealedModeStatus> GetStatusFromHeuristicsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Using heuristic detection for sealed mode status.");
|
||||
|
||||
var score = 0.0;
|
||||
var weights = 0.0;
|
||||
|
||||
// Check AIRGAP_MODE environment variable (high weight)
|
||||
var airgapMode = Environment.GetEnvironmentVariable("AIRGAP_MODE");
|
||||
if (string.Equals(airgapMode, "sealed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 0.3;
|
||||
}
|
||||
weights += 0.3;
|
||||
|
||||
// Check for sealed file marker (medium weight)
|
||||
var sealedMarkerPath = _options.Value.SealedMarkerPath;
|
||||
if (!string.IsNullOrWhiteSpace(sealedMarkerPath) && File.Exists(sealedMarkerPath))
|
||||
{
|
||||
score += 0.2;
|
||||
}
|
||||
weights += 0.2;
|
||||
|
||||
// Check network connectivity (high weight)
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
|
||||
var testResponse = await _httpClient.GetAsync(
|
||||
_options.Value.ConnectivityTestUrl,
|
||||
cts.Token).ConfigureAwait(false);
|
||||
|
||||
// If we can reach external network, likely not sealed
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Network blocked, likely sealed
|
||||
score += 0.3;
|
||||
}
|
||||
weights += 0.3;
|
||||
|
||||
// Check for local registry configuration (low weight)
|
||||
var registryEnv = Environment.GetEnvironmentVariable("CONTAINER_REGISTRY");
|
||||
if (!string.IsNullOrWhiteSpace(registryEnv) &&
|
||||
(registryEnv.Contains("localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
registryEnv.Contains("127.0.0.1", StringComparison.Ordinal)))
|
||||
{
|
||||
score += 0.1;
|
||||
}
|
||||
weights += 0.1;
|
||||
|
||||
// Check proxy settings (low weight)
|
||||
var httpProxy = Environment.GetEnvironmentVariable("HTTP_PROXY") ??
|
||||
Environment.GetEnvironmentVariable("http_proxy");
|
||||
var noProxy = Environment.GetEnvironmentVariable("NO_PROXY") ??
|
||||
Environment.GetEnvironmentVariable("no_proxy");
|
||||
if (string.IsNullOrWhiteSpace(httpProxy) && !string.IsNullOrWhiteSpace(noProxy))
|
||||
{
|
||||
score += 0.1;
|
||||
}
|
||||
weights += 0.1;
|
||||
|
||||
var normalizedScore = weights > 0 ? score / weights : 0;
|
||||
var threshold = _options.Value.HeuristicThreshold;
|
||||
|
||||
var isSealed = normalizedScore >= threshold;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Heuristic detection result: score={Score:F2}, threshold={Threshold:F2}, sealed={IsSealed}",
|
||||
normalizedScore,
|
||||
threshold,
|
||||
isSealed);
|
||||
|
||||
return new SealedModeStatus(
|
||||
Sealed: isSealed,
|
||||
Mode: isSealed ? "sealed-heuristic" : "unsealed-heuristic",
|
||||
SealedAt: null,
|
||||
SealedBy: null,
|
||||
BundleVersion: null,
|
||||
BundleDigest: null,
|
||||
LastAdvisoryUpdate: null,
|
||||
AdvisoryStalenessHours: 0,
|
||||
TimeAnchor: null,
|
||||
EgressBlocked: isSealed,
|
||||
NetworkPolicy: isSealed ? "heuristic-detected" : null);
|
||||
}
|
||||
|
||||
private sealed record AirGapStatusDto(
|
||||
[property: JsonPropertyName("sealed")] bool Sealed,
|
||||
[property: JsonPropertyName("sealed_at")] DateTimeOffset? SealedAt,
|
||||
[property: JsonPropertyName("sealed_by")] string? SealedBy,
|
||||
[property: JsonPropertyName("bundle_version")] string? BundleVersion,
|
||||
[property: JsonPropertyName("bundle_digest")] string? BundleDigest,
|
||||
[property: JsonPropertyName("last_advisory_update")] DateTimeOffset? LastAdvisoryUpdate,
|
||||
[property: JsonPropertyName("advisory_staleness_hours")] int AdvisoryStalenessHours,
|
||||
[property: JsonPropertyName("time_anchor")] TimeAnchorDto? TimeAnchor,
|
||||
[property: JsonPropertyName("egress_blocked")] bool EgressBlocked,
|
||||
[property: JsonPropertyName("network_policy")] string? NetworkPolicy);
|
||||
|
||||
private sealed record TimeAnchorDto(
|
||||
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
|
||||
[property: JsonPropertyName("signature")] string? Signature,
|
||||
[property: JsonPropertyName("valid")] bool Valid,
|
||||
[property: JsonPropertyName("expires_at")] DateTimeOffset? ExpiresAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the HTTP air-gap status provider.
|
||||
/// </summary>
|
||||
public sealed class AirGapStatusProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URL of the AirGap controller.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "http://localhost:8080";
|
||||
|
||||
/// <summary>
|
||||
/// Status endpoint path.
|
||||
/// </summary>
|
||||
public string StatusEndpoint { get; set; } = "/api/v1/airgap/status";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use heuristic fallback when controller is unavailable.
|
||||
/// </summary>
|
||||
public bool UseHeuristicFallback { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Heuristic score threshold (0.0-1.0) to consider environment sealed.
|
||||
/// </summary>
|
||||
public double HeuristicThreshold { get; set; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Path to the sealed mode marker file.
|
||||
/// </summary>
|
||||
public string? SealedMarkerPath { get; set; } = "/etc/stellaops/sealed";
|
||||
|
||||
/// <summary>
|
||||
/// URL to test external connectivity.
|
||||
/// </summary>
|
||||
public string ConnectivityTestUrl { get; set; } = "https://api.stellaops.org/health";
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.TaskRunner.WebService.Deprecation;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class ApiDeprecationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DeprecatedEndpoint_PathPattern_MatchesExpected()
|
||||
{
|
||||
var endpoint = new DeprecatedEndpoint
|
||||
{
|
||||
PathPattern = "/v1/legacy/*",
|
||||
DeprecatedAt = DateTimeOffset.UtcNow.AddDays(-30),
|
||||
SunsetAt = DateTimeOffset.UtcNow.AddDays(60),
|
||||
ReplacementPath = "/v2/new",
|
||||
Message = "Use the v2 API"
|
||||
};
|
||||
|
||||
Assert.Equal("/v1/legacy/*", endpoint.PathPattern);
|
||||
Assert.NotNull(endpoint.DeprecatedAt);
|
||||
Assert.NotNull(endpoint.SunsetAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiDeprecationOptions_DefaultValues_AreCorrect()
|
||||
{
|
||||
var options = new ApiDeprecationOptions();
|
||||
|
||||
Assert.True(options.EmitDeprecationHeaders);
|
||||
Assert.True(options.EmitSunsetHeaders);
|
||||
Assert.NotNull(options.DeprecationPolicyUrl);
|
||||
Assert.Empty(options.DeprecatedEndpoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoggingDeprecationNotificationService_GetUpcoming_FiltersCorrectly()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var options = new ApiDeprecationOptions
|
||||
{
|
||||
DeprecatedEndpoints =
|
||||
[
|
||||
new DeprecatedEndpoint
|
||||
{
|
||||
PathPattern = "/v1/soon/*",
|
||||
SunsetAt = now.AddDays(30) // Within 90 days
|
||||
},
|
||||
new DeprecatedEndpoint
|
||||
{
|
||||
PathPattern = "/v1/later/*",
|
||||
SunsetAt = now.AddDays(180) // Beyond 90 days
|
||||
},
|
||||
new DeprecatedEndpoint
|
||||
{
|
||||
PathPattern = "/v1/past/*",
|
||||
SunsetAt = now.AddDays(-10) // Already passed
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var optionsMonitor = new OptionsMonitor(options);
|
||||
var service = new LoggingDeprecationNotificationService(
|
||||
NullLogger<LoggingDeprecationNotificationService>.Instance,
|
||||
optionsMonitor);
|
||||
|
||||
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Single(upcoming);
|
||||
Assert.Equal("/v1/soon/*", upcoming[0].EndpointPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoggingDeprecationNotificationService_GetUpcoming_OrdersBySunsetDate()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var options = new ApiDeprecationOptions
|
||||
{
|
||||
DeprecatedEndpoints =
|
||||
[
|
||||
new DeprecatedEndpoint { PathPattern = "/v1/third/*", SunsetAt = now.AddDays(60) },
|
||||
new DeprecatedEndpoint { PathPattern = "/v1/first/*", SunsetAt = now.AddDays(10) },
|
||||
new DeprecatedEndpoint { PathPattern = "/v1/second/*", SunsetAt = now.AddDays(30) }
|
||||
]
|
||||
};
|
||||
|
||||
var optionsMonitor = new OptionsMonitor(options);
|
||||
var service = new LoggingDeprecationNotificationService(
|
||||
NullLogger<LoggingDeprecationNotificationService>.Instance,
|
||||
optionsMonitor);
|
||||
|
||||
var upcoming = await service.GetUpcomingDeprecationsAsync(90, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(3, upcoming.Count);
|
||||
Assert.Equal("/v1/first/*", upcoming[0].EndpointPath);
|
||||
Assert.Equal("/v1/second/*", upcoming[1].EndpointPath);
|
||||
Assert.Equal("/v1/third/*", upcoming[2].EndpointPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationInfo_DaysUntilSunset_CalculatesCorrectly()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sunsetDate = now.AddDays(45);
|
||||
|
||||
var info = new DeprecationInfo(
|
||||
"/v1/test/*",
|
||||
now.AddDays(-30),
|
||||
sunsetDate,
|
||||
"/v2/test/*",
|
||||
"https://docs.example.com/migration",
|
||||
45);
|
||||
|
||||
Assert.Equal(45, info.DaysUntilSunset);
|
||||
Assert.Equal("/v2/test/*", info.ReplacementPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeprecationNotification_RecordProperties_AreAccessible()
|
||||
{
|
||||
var notification = new DeprecationNotification(
|
||||
"/v1/legacy/endpoint",
|
||||
"/v2/new/endpoint",
|
||||
DateTimeOffset.UtcNow.AddDays(90),
|
||||
"This endpoint is deprecated",
|
||||
"https://docs.example.com/deprecation",
|
||||
["consumer-1", "consumer-2"]);
|
||||
|
||||
Assert.Equal("/v1/legacy/endpoint", notification.EndpointPath);
|
||||
Assert.Equal("/v2/new/endpoint", notification.ReplacementPath);
|
||||
Assert.NotNull(notification.SunsetDate);
|
||||
Assert.Equal(2, notification.AffectedConsumerIds?.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathPattern_WildcardToRegex_MatchesSingleSegment()
|
||||
{
|
||||
var pattern = "^" + Regex.Escape("/v1/packs/*")
|
||||
.Replace("\\*\\*", ".*")
|
||||
.Replace("\\*", "[^/]*") + "$";
|
||||
|
||||
Assert.Matches(pattern, "/v1/packs/foo");
|
||||
Assert.Matches(pattern, "/v1/packs/bar");
|
||||
Assert.DoesNotMatch(pattern, "/v1/packs/foo/bar"); // Single * shouldn't match /
|
||||
Assert.DoesNotMatch(pattern, "/v2/packs/foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathPattern_DoubleWildcard_MatchesMultipleSegments()
|
||||
{
|
||||
var pattern = "^" + Regex.Escape("/v1/legacy/**")
|
||||
.Replace("\\*\\*", ".*")
|
||||
.Replace("\\*", "[^/]*") + "$";
|
||||
|
||||
Assert.Matches(pattern, "/v1/legacy/foo");
|
||||
Assert.Matches(pattern, "/v1/legacy/foo/bar");
|
||||
Assert.Matches(pattern, "/v1/legacy/foo/bar/baz");
|
||||
Assert.DoesNotMatch(pattern, "/v2/legacy/foo");
|
||||
}
|
||||
|
||||
private sealed class OptionsMonitor : IOptionsMonitor<ApiDeprecationOptions>
|
||||
{
|
||||
public OptionsMonitor(ApiDeprecationOptions value) => CurrentValue = value;
|
||||
|
||||
public ApiDeprecationOptions CurrentValue { get; }
|
||||
|
||||
public ApiDeprecationOptions Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable? OnChange(Action<ApiDeprecationOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,15 @@ public sealed class OpenApiMetadataFactoryTests
|
||||
{
|
||||
var metadata = OpenApiMetadataFactory.Create();
|
||||
|
||||
Assert.Equal("/openapi", metadata.Url);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.Build));
|
||||
Assert.Equal("/openapi", metadata.SpecUrl);
|
||||
Assert.Equal(OpenApiMetadataFactory.ApiVersion, metadata.Version);
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.BuildVersion));
|
||||
Assert.StartsWith("W/\"", metadata.ETag);
|
||||
Assert.EndsWith("\"", metadata.ETag);
|
||||
Assert.Equal(64, metadata.Signature.Length);
|
||||
Assert.True(metadata.Signature.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
|
||||
Assert.StartsWith("sha256:", metadata.Signature);
|
||||
var hashPart = metadata.Signature["sha256:".Length..];
|
||||
Assert.Equal(64, hashPart.Length);
|
||||
Assert.True(hashPart.All(c => char.IsDigit(c) || (c >= 'a' && c <= 'f')));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -22,6 +25,26 @@ public sealed class OpenApiMetadataFactoryTests
|
||||
{
|
||||
var metadata = OpenApiMetadataFactory.Create("/docs/openapi.json");
|
||||
|
||||
Assert.Equal("/docs/openapi.json", metadata.Url);
|
||||
Assert.Equal("/docs/openapi.json", metadata.SpecUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_SignatureIncludesAllComponents()
|
||||
{
|
||||
var metadata1 = OpenApiMetadataFactory.Create("/path1");
|
||||
var metadata2 = OpenApiMetadataFactory.Create("/path2");
|
||||
|
||||
// Different URLs should produce different signatures
|
||||
Assert.NotEqual(metadata1.Signature, metadata2.Signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ETagIsDeterministic()
|
||||
{
|
||||
var metadata1 = OpenApiMetadataFactory.Create();
|
||||
var metadata2 = OpenApiMetadataFactory.Create();
|
||||
|
||||
// Same inputs should produce same ETag
|
||||
Assert.Equal(metadata1.ETag, metadata2.ETag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Core\StellaOps.TaskRunner.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Infrastructure\StellaOps.TaskRunner.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.TaskRunner.WebService\StellaOps.TaskRunner.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\AirGap\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" />
|
||||
<!-- OpenApiMetadataFactory is now accessible via WebService project reference -->
|
||||
<!-- <Compile Include="..\StellaOps.TaskRunner.WebService\OpenApiMetadataFactory.cs" Link="Web/OpenApiMetadataFactory.cs" /> -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Text;
|
||||
using StellaOps.TaskRunner.Client.Models;
|
||||
using StellaOps.TaskRunner.Client.Streaming;
|
||||
using StellaOps.TaskRunner.Client.Pagination;
|
||||
using StellaOps.TaskRunner.Client.Lifecycle;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
public sealed class TaskRunnerClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StreamingLogReader_ParsesNdjsonLines()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var ndjson = """
|
||||
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Starting","traceId":"abc123"}
|
||||
{"timestamp":"2025-01-01T00:00:01Z","level":"error","stepId":"step-1","message":"Failed","traceId":"abc123"}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
var entries = await StreamingLogReader.CollectAsync(stream, ct);
|
||||
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal("info", entries[0].Level);
|
||||
Assert.Equal("error", entries[1].Level);
|
||||
Assert.Equal("step-1", entries[0].StepId);
|
||||
Assert.Equal("Starting", entries[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamingLogReader_SkipsEmptyLines()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var ndjson = """
|
||||
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Test","traceId":"abc123"}
|
||||
|
||||
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"Test2","traceId":"abc123"}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
var entries = await StreamingLogReader.CollectAsync(stream, ct);
|
||||
|
||||
Assert.Equal(2, entries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamingLogReader_SkipsMalformedLines()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var ndjson = """
|
||||
{"timestamp":"2025-01-01T00:00:00Z","level":"info","stepId":"step-1","message":"Valid","traceId":"abc123"}
|
||||
not valid json
|
||||
{"timestamp":"2025-01-01T00:00:01Z","level":"info","stepId":"step-2","message":"AlsoValid","traceId":"abc123"}
|
||||
""";
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ndjson));
|
||||
|
||||
var entries = await StreamingLogReader.CollectAsync(stream, ct);
|
||||
|
||||
Assert.Equal(2, entries.Count);
|
||||
Assert.Equal("Valid", entries[0].Message);
|
||||
Assert.Equal("AlsoValid", entries[1].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamingLogReader_FilterByLevel_FiltersCorrectly()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var entries = new List<RunLogEntry>
|
||||
{
|
||||
new(DateTimeOffset.UtcNow, "info", "step-1", "Info message", "trace1"),
|
||||
new(DateTimeOffset.UtcNow, "error", "step-1", "Error message", "trace1"),
|
||||
new(DateTimeOffset.UtcNow, "warning", "step-1", "Warning message", "trace1"),
|
||||
};
|
||||
|
||||
var levels = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "error", "warning" };
|
||||
var filtered = new List<RunLogEntry>();
|
||||
|
||||
await foreach (var entry in StreamingLogReader.FilterByLevelAsync(entries.ToAsyncEnumerable(), levels, ct))
|
||||
{
|
||||
filtered.Add(entry);
|
||||
}
|
||||
|
||||
Assert.Equal(2, filtered.Count);
|
||||
Assert.DoesNotContain(filtered, e => e.Level == "info");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamingLogReader_GroupByStep_GroupsCorrectly()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var entries = new List<RunLogEntry>
|
||||
{
|
||||
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 1", "trace1"),
|
||||
new(DateTimeOffset.UtcNow, "info", "step-2", "Message 2", "trace1"),
|
||||
new(DateTimeOffset.UtcNow, "info", "step-1", "Message 3", "trace1"),
|
||||
new(DateTimeOffset.UtcNow, "info", null, "Global message", "trace1"),
|
||||
};
|
||||
|
||||
var groups = await StreamingLogReader.GroupByStepAsync(entries.ToAsyncEnumerable(), ct);
|
||||
|
||||
Assert.Equal(3, groups.Count);
|
||||
Assert.Equal(2, groups["step-1"].Count);
|
||||
Assert.Single(groups["step-2"]);
|
||||
Assert.Single(groups["(global)"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paginator_IteratesAllPages()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var allItems = Enumerable.Range(1, 25).ToList();
|
||||
var pageSize = 10;
|
||||
var fetchCalls = 0;
|
||||
|
||||
var paginator = new Paginator<int>(
|
||||
async (offset, limit, token) =>
|
||||
{
|
||||
fetchCalls++;
|
||||
var items = allItems.Skip(offset).Take(limit).ToList();
|
||||
var hasMore = offset + items.Count < allItems.Count;
|
||||
return new PagedResponse<int>(items, allItems.Count, hasMore);
|
||||
},
|
||||
pageSize);
|
||||
|
||||
var collected = await paginator.CollectAsync(ct);
|
||||
|
||||
Assert.Equal(25, collected.Count);
|
||||
Assert.Equal(3, fetchCalls); // 10, 10, 5 items
|
||||
Assert.Equal(allItems, collected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paginator_GetPage_ReturnsCorrectPage()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var allItems = Enumerable.Range(1, 25).ToList();
|
||||
var pageSize = 10;
|
||||
|
||||
var paginator = new Paginator<int>(
|
||||
async (offset, limit, token) =>
|
||||
{
|
||||
var items = allItems.Skip(offset).Take(limit).ToList();
|
||||
var hasMore = offset + items.Count < allItems.Count;
|
||||
return new PagedResponse<int>(items, allItems.Count, hasMore);
|
||||
},
|
||||
pageSize);
|
||||
|
||||
var page2 = await paginator.GetPageAsync(2, ct);
|
||||
|
||||
Assert.Equal(10, page2.Items.Count);
|
||||
Assert.Equal(11, page2.Items[0]); // Items 11-20
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PaginatorExtensions_TakeAsync_TakesCorrectNumber()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var items = Enumerable.Range(1, 100).ToAsyncEnumerable();
|
||||
|
||||
var taken = new List<int>();
|
||||
await foreach (var item in items.TakeAsync(5, ct))
|
||||
{
|
||||
taken.Add(item);
|
||||
}
|
||||
|
||||
Assert.Equal(5, taken.Count);
|
||||
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, taken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PaginatorExtensions_SkipAsync_SkipsCorrectNumber()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var items = Enumerable.Range(1, 10).ToAsyncEnumerable();
|
||||
|
||||
var skipped = new List<int>();
|
||||
await foreach (var item in items.SkipAsync(5, ct))
|
||||
{
|
||||
skipped.Add(item);
|
||||
}
|
||||
|
||||
Assert.Equal(5, skipped.Count);
|
||||
Assert.Equal(new[] { 6, 7, 8, 9, 10 }, skipped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackRunLifecycleHelper_TerminalStatuses_IncludesExpectedStatuses()
|
||||
{
|
||||
Assert.Contains("completed", PackRunLifecycleHelper.TerminalStatuses);
|
||||
Assert.Contains("failed", PackRunLifecycleHelper.TerminalStatuses);
|
||||
Assert.Contains("cancelled", PackRunLifecycleHelper.TerminalStatuses);
|
||||
Assert.Contains("rejected", PackRunLifecycleHelper.TerminalStatuses);
|
||||
Assert.DoesNotContain("running", PackRunLifecycleHelper.TerminalStatuses);
|
||||
Assert.DoesNotContain("pending", PackRunLifecycleHelper.TerminalStatuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackRunModels_CreatePackRunRequest_SerializesCorrectly()
|
||||
{
|
||||
var request = new CreatePackRunRequest(
|
||||
"my-pack",
|
||||
"1.0.0",
|
||||
new Dictionary<string, object> { ["key"] = "value" },
|
||||
"tenant-1",
|
||||
"corr-123");
|
||||
|
||||
Assert.Equal("my-pack", request.PackId);
|
||||
Assert.Equal("1.0.0", request.PackVersion);
|
||||
Assert.NotNull(request.Inputs);
|
||||
Assert.Equal("value", request.Inputs["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PackRunModels_SimulatedStep_HasCorrectProperties()
|
||||
{
|
||||
var loopInfo = new LoopInfo("{{ inputs.items }}", "item", 100);
|
||||
var step = new SimulatedStep(
|
||||
"step-1",
|
||||
"loop",
|
||||
"WillIterate",
|
||||
loopInfo,
|
||||
null,
|
||||
null);
|
||||
|
||||
Assert.Equal("step-1", step.StepId);
|
||||
Assert.Equal("loop", step.Kind);
|
||||
Assert.NotNull(step.LoopInfo);
|
||||
Assert.Equal("{{ inputs.items }}", step.LoopInfo.ItemsExpression);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AsyncEnumerableExtensions
|
||||
{
|
||||
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware that adds deprecation and sunset headers per RFC 8594.
|
||||
/// </summary>
|
||||
public sealed class ApiDeprecationMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
|
||||
private readonly ILogger<ApiDeprecationMiddleware> _logger;
|
||||
private readonly List<CompiledEndpointPattern> _patterns;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP header for deprecation status per draft-ietf-httpapi-deprecation-header.
|
||||
/// </summary>
|
||||
public const string DeprecationHeader = "Deprecation";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP header for sunset date per RFC 8594.
|
||||
/// </summary>
|
||||
public const string SunsetHeader = "Sunset";
|
||||
|
||||
/// <summary>
|
||||
/// HTTP Link header for deprecation documentation.
|
||||
/// </summary>
|
||||
public const string LinkHeader = "Link";
|
||||
|
||||
public ApiDeprecationMiddleware(
|
||||
RequestDelegate next,
|
||||
IOptionsMonitor<ApiDeprecationOptions> options,
|
||||
ILogger<ApiDeprecationMiddleware> logger)
|
||||
{
|
||||
_next = next ?? throw new ArgumentNullException(nameof(next));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_patterns = CompilePatterns(options.CurrentValue.DeprecatedEndpoints);
|
||||
|
||||
options.OnChange(newOptions =>
|
||||
{
|
||||
_patterns.Clear();
|
||||
_patterns.AddRange(CompilePatterns(newOptions.DeprecatedEndpoints));
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
|
||||
var deprecatedEndpoint = FindMatchingEndpoint(path);
|
||||
|
||||
if (deprecatedEndpoint is not null)
|
||||
{
|
||||
AddDeprecationHeaders(context.Response, deprecatedEndpoint, options);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deprecated endpoint accessed: {Path} (sunset: {Sunset})",
|
||||
path,
|
||||
deprecatedEndpoint.Config.SunsetAt?.ToString("o", CultureInfo.InvariantCulture) ?? "not set");
|
||||
}
|
||||
|
||||
await _next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private CompiledEndpointPattern? FindMatchingEndpoint(string path)
|
||||
{
|
||||
foreach (var pattern in _patterns)
|
||||
{
|
||||
if (pattern.Regex.IsMatch(path))
|
||||
{
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddDeprecationHeaders(
|
||||
HttpResponse response,
|
||||
CompiledEndpointPattern endpoint,
|
||||
ApiDeprecationOptions options)
|
||||
{
|
||||
var config = endpoint.Config;
|
||||
|
||||
// Add Deprecation header per draft-ietf-httpapi-deprecation-header
|
||||
if (options.EmitDeprecationHeaders && config.DeprecatedAt.HasValue)
|
||||
{
|
||||
// RFC 7231 date format: Sun, 06 Nov 1994 08:49:37 GMT
|
||||
var deprecationDate = config.DeprecatedAt.Value.ToString("R", CultureInfo.InvariantCulture);
|
||||
response.Headers.Append(DeprecationHeader, deprecationDate);
|
||||
}
|
||||
else if (options.EmitDeprecationHeaders)
|
||||
{
|
||||
// If no specific date, use "true" to indicate deprecated
|
||||
response.Headers.Append(DeprecationHeader, "true");
|
||||
}
|
||||
|
||||
// Add Sunset header per RFC 8594
|
||||
if (options.EmitSunsetHeaders && config.SunsetAt.HasValue)
|
||||
{
|
||||
var sunsetDate = config.SunsetAt.Value.ToString("R", CultureInfo.InvariantCulture);
|
||||
response.Headers.Append(SunsetHeader, sunsetDate);
|
||||
}
|
||||
|
||||
// Add Link headers for documentation
|
||||
var links = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.DeprecationLink))
|
||||
{
|
||||
links.Add($"<{config.DeprecationLink}>; rel=\"deprecation\"; type=\"text/html\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.DeprecationPolicyUrl))
|
||||
{
|
||||
links.Add($"<{options.DeprecationPolicyUrl}>; rel=\"sunset\"; type=\"text/html\"");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ReplacementPath))
|
||||
{
|
||||
links.Add($"<{config.ReplacementPath}>; rel=\"successor-version\"");
|
||||
}
|
||||
|
||||
if (links.Count > 0)
|
||||
{
|
||||
response.Headers.Append(LinkHeader, string.Join(", ", links));
|
||||
}
|
||||
|
||||
// Add custom deprecation message header
|
||||
if (!string.IsNullOrWhiteSpace(config.Message))
|
||||
{
|
||||
response.Headers.Append("X-Deprecation-Notice", config.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CompiledEndpointPattern> CompilePatterns(List<DeprecatedEndpoint> endpoints)
|
||||
{
|
||||
var patterns = new List<CompiledEndpointPattern>(endpoints.Count);
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(endpoint.PathPattern))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert wildcard pattern to regex
|
||||
var pattern = "^" + Regex.Escape(endpoint.PathPattern)
|
||||
.Replace("\\*\\*", ".*")
|
||||
.Replace("\\*", "[^/]*") + "$";
|
||||
|
||||
try
|
||||
{
|
||||
var regex = new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
patterns.Add(new CompiledEndpointPattern(regex, endpoint));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Invalid regex pattern, skip
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
private sealed record CompiledEndpointPattern(Regex Regex, DeprecatedEndpoint Config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for adding API deprecation middleware.
|
||||
/// </summary>
|
||||
public static class ApiDeprecationMiddlewareExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the API deprecation middleware to the pipeline.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseApiDeprecation(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<ApiDeprecationMiddleware>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds API deprecation services to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApiDeprecation(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<ApiDeprecationOptions>(
|
||||
configuration.GetSection(ApiDeprecationOptions.SectionName));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for API deprecation and sunset headers.
|
||||
/// </summary>
|
||||
public sealed class ApiDeprecationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "TaskRunner:ApiDeprecation";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit deprecation headers for deprecated endpoints.
|
||||
/// </summary>
|
||||
public bool EmitDeprecationHeaders { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to emit sunset headers per RFC 8594.
|
||||
/// </summary>
|
||||
public bool EmitSunsetHeaders { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// URL to deprecation policy documentation.
|
||||
/// </summary>
|
||||
public string? DeprecationPolicyUrl { get; set; } = "https://docs.stellaops.io/api/deprecation-policy";
|
||||
|
||||
/// <summary>
|
||||
/// List of deprecated endpoints with their sunset dates.
|
||||
/// </summary>
|
||||
public List<DeprecatedEndpoint> DeprecatedEndpoints { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a deprecated endpoint.
|
||||
/// </summary>
|
||||
public sealed class DeprecatedEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Path pattern to match (supports wildcards like /v1/packs/*).
|
||||
/// </summary>
|
||||
public string PathPattern { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Date when the endpoint was deprecated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DeprecatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Date when the endpoint will be removed (sunset date per RFC 8594).
|
||||
/// </summary>
|
||||
public DateTimeOffset? SunsetAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to documentation about the deprecation and migration path.
|
||||
/// </summary>
|
||||
public string? DeprecationLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested replacement endpoint path.
|
||||
/// </summary>
|
||||
public string? ReplacementPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable deprecation message.
|
||||
/// </summary>
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.TaskRunner.WebService.Deprecation;
|
||||
|
||||
/// <summary>
|
||||
/// Service for sending deprecation notifications to API consumers.
|
||||
/// </summary>
|
||||
public interface IDeprecationNotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a notification about an upcoming deprecation.
|
||||
/// </summary>
|
||||
/// <param name="notification">Deprecation notification details.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets upcoming deprecations within a specified number of days.
|
||||
/// </summary>
|
||||
/// <param name="withinDays">Number of days to look ahead.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of upcoming deprecations.</returns>
|
||||
Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
|
||||
int withinDays = 90,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deprecation notification details.
|
||||
/// </summary>
|
||||
public sealed record DeprecationNotification(
|
||||
string EndpointPath,
|
||||
string? ReplacementPath,
|
||||
DateTimeOffset? SunsetDate,
|
||||
string? Message,
|
||||
string? DocumentationUrl,
|
||||
IReadOnlyList<string>? AffectedConsumerIds);
|
||||
|
||||
/// <summary>
|
||||
/// Information about a deprecation.
|
||||
/// </summary>
|
||||
public sealed record DeprecationInfo(
|
||||
string EndpointPath,
|
||||
DateTimeOffset? DeprecatedAt,
|
||||
DateTimeOffset? SunsetAt,
|
||||
string? ReplacementPath,
|
||||
string? DocumentationUrl,
|
||||
int DaysUntilSunset);
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that logs deprecation notifications.
|
||||
/// </summary>
|
||||
public sealed class LoggingDeprecationNotificationService : IDeprecationNotificationService
|
||||
{
|
||||
private readonly ILogger<LoggingDeprecationNotificationService> _logger;
|
||||
private readonly IOptionsMonitor<ApiDeprecationOptions> _options;
|
||||
|
||||
public LoggingDeprecationNotificationService(
|
||||
ILogger<LoggingDeprecationNotificationService> logger,
|
||||
IOptionsMonitor<ApiDeprecationOptions> options)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public Task NotifyAsync(DeprecationNotification notification, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Deprecation notification: Endpoint {Endpoint} will be sunset on {SunsetDate}. " +
|
||||
"Replacement: {Replacement}. Message: {Message}",
|
||||
notification.EndpointPath,
|
||||
notification.SunsetDate?.ToString("o"),
|
||||
notification.ReplacementPath ?? "(none)",
|
||||
notification.Message ?? "(none)");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeprecationInfo>> GetUpcomingDeprecationsAsync(
|
||||
int withinDays = 90,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var cutoff = now.AddDays(withinDays);
|
||||
|
||||
var upcoming = options.DeprecatedEndpoints
|
||||
.Where(e => e.SunsetAt.HasValue && e.SunsetAt.Value <= cutoff && e.SunsetAt.Value > now)
|
||||
.OrderBy(e => e.SunsetAt)
|
||||
.Select(e => new DeprecationInfo(
|
||||
e.PathPattern,
|
||||
e.DeprecatedAt,
|
||||
e.SunsetAt,
|
||||
e.ReplacementPath,
|
||||
e.DeprecationLink,
|
||||
e.SunsetAt.HasValue ? (int)(e.SunsetAt.Value - now).TotalDays : int.MaxValue))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DeprecationInfo>>(upcoming);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace StellaOps.TaskRunner.WebService;
|
||||
/// <summary>
|
||||
/// Factory for creating OpenAPI metadata including version, build info, and spec signature.
|
||||
/// </summary>
|
||||
internal static class OpenApiMetadataFactory
|
||||
public static class OpenApiMetadataFactory
|
||||
{
|
||||
/// <summary>API version from the OpenAPI spec (docs/api/taskrunner-openapi.yaml).</summary>
|
||||
public const string ApiVersion = "0.1.0-draft";
|
||||
@@ -73,7 +73,7 @@ internal static class OpenApiMetadataFactory
|
||||
/// <param name="BuildVersion">Build/assembly version with optional git info.</param>
|
||||
/// <param name="ETag">ETag for HTTP caching.</param>
|
||||
/// <param name="Signature">SHA-256 signature for verification.</param>
|
||||
internal sealed record OpenApiMetadata(
|
||||
public sealed record OpenApiMetadata(
|
||||
string SpecUrl,
|
||||
string Version,
|
||||
string BuildVersion,
|
||||
|
||||
@@ -5,7 +5,10 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using MongoDB.Driver;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -17,6 +20,7 @@ using StellaOps.TaskRunner.Core.Planning;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
using StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
using StellaOps.TaskRunner.WebService;
|
||||
using StellaOps.TaskRunner.WebService.Deprecation;
|
||||
using StellaOps.Telemetry.Core;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -95,12 +99,42 @@ builder.Services.AddSingleton(sp =>
|
||||
});
|
||||
builder.Services.AddSingleton<IPackRunJobScheduler>(sp => sp.GetRequiredService<FilesystemPackRunDispatcher>());
|
||||
builder.Services.AddSingleton<PackRunApprovalDecisionService>();
|
||||
builder.Services.AddApiDeprecation(builder.Configuration);
|
||||
builder.Services.AddSingleton<IDeprecationNotificationService, LoggingDeprecationNotificationService>();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Add deprecation middleware for sunset headers (RFC 8594)
|
||||
app.UseApiDeprecation();
|
||||
|
||||
app.MapOpenApi("/openapi");
|
||||
|
||||
// Deprecation status endpoint
|
||||
app.MapGet("/v1/task-runner/deprecations", async (
|
||||
IDeprecationNotificationService deprecationService,
|
||||
[FromQuery] int? withinDays,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var days = withinDays ?? 90;
|
||||
var deprecations = await deprecationService.GetUpcomingDeprecationsAsync(days, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
withinDays = days,
|
||||
deprecations = deprecations.Select(d => new
|
||||
{
|
||||
endpoint = d.EndpointPath,
|
||||
deprecatedAt = d.DeprecatedAt?.ToString("o"),
|
||||
sunsetAt = d.SunsetAt?.ToString("o"),
|
||||
daysUntilSunset = d.DaysUntilSunset,
|
||||
replacement = d.ReplacementPath,
|
||||
documentation = d.DocumentationUrl
|
||||
})
|
||||
});
|
||||
}).WithName("GetDeprecations").WithTags("API Governance");
|
||||
|
||||
app.MapPost("/v1/task-runner/simulations", async (
|
||||
[FromBody] SimulationRequest request,
|
||||
TaskPackManifestLoader loader,
|
||||
@@ -290,11 +324,11 @@ async Task<IResult> HandleStreamRunLogs(
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Stream(async (stream, ct) =>
|
||||
return Results.Stream(async stream =>
|
||||
{
|
||||
await foreach (var entry in logStore.ReadAsync(runId, ct).ConfigureAwait(false))
|
||||
await foreach (var entry in logStore.ReadAsync(runId, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await RunLogMapper.WriteAsync(stream, entry, ct).ConfigureAwait(false);
|
||||
await RunLogMapper.WriteAsync(stream, entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}, "application/x-ndjson");
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0"/>
|
||||
|
||||
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Worker
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Tests", "StellaOps.TaskRunner.Tests\StellaOps.TaskRunner.Tests.csproj", "{552E7C8A-74F6-4E33-B956-46DF96E2BE11}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TaskRunner.Client", "StellaOps.TaskRunner.Client\StellaOps.TaskRunner.Client.csproj", "{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -83,6 +85,18 @@ Global
|
||||
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x64.Build.0 = Release|Any CPU
|
||||
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{552E7C8A-74F6-4E33-B956-46DF96E2BE11}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7514BF42-5D6F-4D1B-AD1E-754479BFEDE4}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -2,12 +2,6 @@ import { Injectable } from '@angular/core';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline';
|
||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker&inline';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker&inline';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker&inline';
|
||||
|
||||
import {
|
||||
defineStellaDslTheme,
|
||||
registerStellaDslLanguage,
|
||||
@@ -29,11 +23,17 @@ export class MonacoLoaderService {
|
||||
return this.monacoPromise;
|
||||
}
|
||||
|
||||
// In tests, short-circuit with a minimal stub to avoid worker/CSS loading
|
||||
if (typeof (globalThis as any).Jasmine !== 'undefined') {
|
||||
this.monacoPromise = Promise.resolve(this.createStubMonaco());
|
||||
return this.monacoPromise;
|
||||
}
|
||||
|
||||
this.monacoPromise = import(
|
||||
/* webpackChunkName: "monaco-editor" */
|
||||
'monaco-editor/esm/vs/editor/editor.api'
|
||||
).then((monaco) => {
|
||||
this.configureWorkers(monaco);
|
||||
).then(async (monaco) => {
|
||||
await this.configureWorkers(monaco);
|
||||
registerStellaDslLanguage(monaco);
|
||||
defineStellaDslTheme(monaco);
|
||||
registerStellaDslCompletions(monaco);
|
||||
@@ -47,18 +47,26 @@ export class MonacoLoaderService {
|
||||
* Configure Monaco web workers for language services.
|
||||
* Ensures deterministic, offline-friendly loading (no CDN usage).
|
||||
*/
|
||||
private configureWorkers(monaco: MonacoNamespace): void {
|
||||
private async configureWorkers(monaco: MonacoNamespace): Promise<void> {
|
||||
const [editorWorker, cssWorker, htmlWorker, jsonWorker, tsWorker] = await Promise.all([
|
||||
import('monaco-editor/esm/vs/editor/editor.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/css/css.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/html/html.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/json/json.worker?worker'),
|
||||
import('monaco-editor/esm/vs/language/typescript/ts.worker?worker'),
|
||||
]);
|
||||
|
||||
const workerByLabel: Record<string, () => Worker> = {
|
||||
json: () => new jsonWorker(),
|
||||
css: () => new cssWorker(),
|
||||
scss: () => new cssWorker(),
|
||||
less: () => new cssWorker(),
|
||||
html: () => new htmlWorker(),
|
||||
handlebars: () => new htmlWorker(),
|
||||
razor: () => new htmlWorker(),
|
||||
javascript: () => new tsWorker(),
|
||||
typescript: () => new tsWorker(),
|
||||
default: () => new editorWorker(),
|
||||
json: () => new (jsonWorker as any).default(),
|
||||
css: () => new (cssWorker as any).default(),
|
||||
scss: () => new (cssWorker as any).default(),
|
||||
less: () => new (cssWorker as any).default(),
|
||||
html: () => new (htmlWorker as any).default(),
|
||||
handlebars: () => new (htmlWorker as any).default(),
|
||||
razor: () => new (htmlWorker as any).default(),
|
||||
javascript: () => new (tsWorker as any).default(),
|
||||
typescript: () => new (tsWorker as any).default(),
|
||||
default: () => new (editorWorker as any).default(),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -73,4 +81,24 @@ export class MonacoLoaderService {
|
||||
// Set a deterministic default theme baseline (extended by defineStellaDslTheme)
|
||||
monaco.editor.setTheme('vs-dark');
|
||||
}
|
||||
|
||||
private createStubMonaco(): MonacoNamespace {
|
||||
return {
|
||||
editor: {
|
||||
createModel: (value: string) => ({ getValue: () => value, setValue: () => undefined } as any),
|
||||
create: () => ({
|
||||
onDidChangeModelContent: () => ({ dispose: () => undefined }),
|
||||
dispose: () => undefined,
|
||||
} as any),
|
||||
setModelMarkers: () => undefined,
|
||||
setTheme: () => undefined,
|
||||
},
|
||||
languages: {
|
||||
register: () => undefined,
|
||||
setMonarchTokensProvider: () => undefined,
|
||||
setLanguageConfiguration: () => undefined,
|
||||
},
|
||||
MarkerSeverity: { Error: 8, Warning: 4, Info: 2 },
|
||||
} as unknown as MonacoNamespace;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,22 +7,22 @@ import { PolicyEditorComponent } from './policy-editor.component';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { MonacoLoaderService } from './monaco-loader.service';
|
||||
|
||||
// Hard mock Monaco for tests to avoid worker/CSS loading
|
||||
// Minimal Monaco loader stub: no workers/CSS
|
||||
class MonacoLoaderStub {
|
||||
model = {
|
||||
getValue: () => this.value,
|
||||
setValue: (v: string) => (this.value = v),
|
||||
value: '',
|
||||
getValue: () => this.model.value,
|
||||
setValue: (v: string) => (this.model.value = v),
|
||||
} as any;
|
||||
editor = {
|
||||
onDidChangeModelContent: () => ({ dispose: () => undefined }),
|
||||
} as any;
|
||||
lastMarkers: any[] = [];
|
||||
private value = '';
|
||||
|
||||
load = jasmine.createSpy('load').and.resolveTo({
|
||||
editor: {
|
||||
createModel: (v: string) => {
|
||||
this.value = v;
|
||||
this.model.value = v;
|
||||
return this.model;
|
||||
},
|
||||
create: () => this.editor,
|
||||
@@ -54,9 +54,18 @@ describe('PolicyEditorComponent', () => {
|
||||
of({
|
||||
id: 'pack-1',
|
||||
name: 'Demo Policy',
|
||||
description: '',
|
||||
syntax: 'stella-dsl@1',
|
||||
content: 'package "demo" { allow = true }',
|
||||
version: '1.0.0',
|
||||
status: 'draft',
|
||||
metadata: {},
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
modifiedAt: '2025-12-02T00:00:00Z',
|
||||
createdBy: 'tester',
|
||||
modifiedBy: 'tester',
|
||||
tags: [],
|
||||
digest: 'sha256:abc',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -88,7 +97,7 @@ describe('PolicyEditorComponent', () => {
|
||||
expect(monacoLoader.model.getValue()).toContain('package "demo"');
|
||||
});
|
||||
|
||||
it('applies lint diagnostics as Monaco markers', () => {
|
||||
it('applies lint diagnostics as markers', () => {
|
||||
const lintResult = {
|
||||
valid: false,
|
||||
errors: [
|
||||
@@ -106,7 +115,6 @@ describe('PolicyEditorComponent', () => {
|
||||
};
|
||||
|
||||
policyApi.lint.and.returnValue(of(lintResult) as any);
|
||||
|
||||
component.triggerLint();
|
||||
|
||||
expect(monacoLoader.lastMarkers.length).toBe(1);
|
||||
|
||||
@@ -550,13 +550,6 @@ export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly subscriptions = new Subscription();
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.isTestEnv()) {
|
||||
// Under tests we rely on stubbed loader; avoid network/worker work
|
||||
this.loadingPack = false;
|
||||
this.content$.next('');
|
||||
return;
|
||||
}
|
||||
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export const editor = {
|
||||
createModel: (_v?: string) => ({}) as any,
|
||||
setModelMarkers: (_m: any, _o: string, _markers: any[]) => undefined,
|
||||
setTheme: (_t: string) => undefined,
|
||||
};
|
||||
|
||||
export const languages = {
|
||||
register: () => undefined,
|
||||
setMonarchTokensProvider: () => undefined,
|
||||
setLanguageConfiguration: () => undefined,
|
||||
};
|
||||
|
||||
export const MarkerSeverity = {
|
||||
Error: 8,
|
||||
Warning: 4,
|
||||
Info: 2,
|
||||
};
|
||||
|
||||
export default { editor, languages, MarkerSeverity } as any;
|
||||
@@ -1,6 +0,0 @@
|
||||
export default class MonacoDummyWorker {
|
||||
postMessage(): void {}
|
||||
addEventListener(): void {}
|
||||
removeEventListener(): void {}
|
||||
terminate(): void {}
|
||||
}
|
||||
@@ -5,15 +5,7 @@
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
],
|
||||
"paths": {
|
||||
"monaco-editor/esm/vs/editor/editor.api": ["src/app/testing/monaco-stub"],
|
||||
"monaco-editor/esm/vs/editor/editor.worker": ["src/app/testing/monaco-worker-stub"],
|
||||
"monaco-editor/esm/vs/language/json/json.worker": ["src/app/testing/monaco-worker-stub"],
|
||||
"monaco-editor/esm/vs/language/css/css.worker": ["src/app/testing/monaco-worker-stub"],
|
||||
"monaco-editor/esm/vs/language/html/html.worker": ["src/app/testing/monaco-worker-stub"],
|
||||
"monaco-editor/esm/vs/language/typescript/ts.worker": ["src/app/testing/monaco-worker-stub"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
|
||||
@@ -528,3 +528,8 @@ public sealed class MigrationRunner : IMigrationRunner
|
||||
private record AppliedMigration(string Name, string Category, string Checksum, DateTimeOffset AppliedAt);
|
||||
private record PendingMigration(string Name, MigrationCategory Category, string Checksum, string Content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an applied migration.
|
||||
/// </summary>
|
||||
public readonly record struct MigrationInfo(string Name, DateTimeOffset AppliedAt, string Checksum);
|
||||
|
||||
BIN
src/codie.png
Normal file
BIN
src/codie.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Reference in New Issue
Block a user