feat: Add Bun language analyzer and related functionality

- Implemented BunPackageNormalizer to deduplicate packages by name and version.
- Created BunProjectDiscoverer to identify Bun project roots in the filesystem.
- Added project files for the Bun analyzer including manifest and project configuration.
- Developed comprehensive tests for Bun language analyzer covering various scenarios.
- Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces.
- Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
StellaOps Bot
2025-12-06 11:20:35 +02:00
parent b978ae399f
commit a7cd10020a
85 changed files with 7414 additions and 42 deletions

View File

@@ -0,0 +1,300 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using StellaOps.Policy.Engine.Ledger;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Service for managing Console export jobs per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
internal sealed partial class ConsoleExportJobService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly Regex CronRegex = CreateCronRegex();
private readonly IConsoleExportJobStore _jobStore;
private readonly IConsoleExportExecutionStore _executionStore;
private readonly IConsoleExportBundleStore _bundleStore;
private readonly LedgerExportService _ledgerExport;
private readonly TimeProvider _timeProvider;
public ConsoleExportJobService(
IConsoleExportJobStore jobStore,
IConsoleExportExecutionStore executionStore,
IConsoleExportBundleStore bundleStore,
LedgerExportService ledgerExport,
TimeProvider timeProvider)
{
_jobStore = jobStore ?? throw new ArgumentNullException(nameof(jobStore));
_executionStore = executionStore ?? throw new ArgumentNullException(nameof(executionStore));
_bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
_ledgerExport = ledgerExport ?? throw new ArgumentNullException(nameof(ledgerExport));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<ExportBundleJob> CreateJobAsync(
string tenantId,
CreateExportJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
ValidateRequest(request);
var now = _timeProvider.GetUtcNow();
var jobId = GenerateId("job");
var job = new ExportBundleJob(
JobId: jobId,
TenantId: tenantId,
Name: request.Name,
Description: request.Description,
Query: request.Query,
Format: request.Format,
Schedule: request.Schedule,
Destination: request.Destination,
Signing: request.Signing,
Enabled: true,
CreatedAt: now.ToString("O"),
LastRunAt: null,
NextRunAt: CalculateNextRun(request.Schedule, now));
await _jobStore.SaveAsync(job, cancellationToken).ConfigureAwait(false);
return job;
}
public async Task<ExportBundleJob?> GetJobAsync(string jobId, CancellationToken cancellationToken = default)
{
return await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
}
public async Task<ListJobsResponse> ListJobsAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
var jobs = await _jobStore.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new ListJobsResponse(jobs, jobs.Count);
}
public async Task<ExportBundleJob> UpdateJobAsync(
string jobId,
UpdateExportJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
ArgumentNullException.ThrowIfNull(request);
var existing = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
if (request.Schedule is not null && !IsValidCron(request.Schedule))
{
throw new ArgumentException("Invalid schedule expression", nameof(request));
}
var now = _timeProvider.GetUtcNow();
var newSchedule = request.Schedule ?? existing.Schedule;
var updated = existing with
{
Name = request.Name ?? existing.Name,
Description = request.Description ?? existing.Description,
Schedule = newSchedule,
Signing = request.Signing ?? existing.Signing,
Enabled = request.Enabled ?? existing.Enabled,
NextRunAt = CalculateNextRun(newSchedule, now)
};
await _jobStore.SaveAsync(updated, cancellationToken).ConfigureAwait(false);
return updated;
}
public async Task DeleteJobAsync(string jobId, CancellationToken cancellationToken = default)
{
await _jobStore.DeleteAsync(jobId, cancellationToken).ConfigureAwait(false);
}
public async Task<TriggerExecutionResponse> TriggerJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var job = await _jobStore.GetAsync(jobId, cancellationToken).ConfigureAwait(false)
?? throw new KeyNotFoundException($"Job '{jobId}' not found");
var now = _timeProvider.GetUtcNow();
var executionId = GenerateId("exec");
var execution = new ExportExecution(
ExecutionId: executionId,
JobId: jobId,
Status: "running",
BundleId: null,
StartedAt: now.ToString("O"),
CompletedAt: null,
Error: null);
await _executionStore.SaveAsync(execution, cancellationToken).ConfigureAwait(false);
// Execute the export asynchronously
_ = ExecuteJobAsync(job, execution, cancellationToken);
return new TriggerExecutionResponse(executionId, "running");
}
public async Task<ExportExecution?> GetExecutionAsync(string executionId, CancellationToken cancellationToken = default)
{
return await _executionStore.GetAsync(executionId, cancellationToken).ConfigureAwait(false);
}
public async Task<ExportBundleManifest?> GetBundleAsync(string bundleId, CancellationToken cancellationToken = default)
{
return await _bundleStore.GetAsync(bundleId, cancellationToken).ConfigureAwait(false);
}
public async Task<byte[]?> GetBundleContentAsync(string bundleId, CancellationToken cancellationToken = default)
{
return await _bundleStore.GetContentAsync(bundleId, cancellationToken).ConfigureAwait(false);
}
private async Task ExecuteJobAsync(ExportBundleJob job, ExportExecution execution, CancellationToken cancellationToken)
{
try
{
// Build ledger export for this tenant
var request = new LedgerExportRequest(job.TenantId);
var ledgerExport = await _ledgerExport.BuildAsync(request, cancellationToken).ConfigureAwait(false);
// Build bundle content based on format
var content = BuildContent(job, ledgerExport);
var contentBytes = Encoding.UTF8.GetBytes(content);
// Create manifest
var now = _timeProvider.GetUtcNow();
var bundleId = GenerateId("bundle");
var artifactDigest = ComputeSha256(contentBytes);
var querySignature = ComputeSha256(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(job.Query, JsonOptions)));
var manifest = new ExportBundleManifest(
BundleId: bundleId,
JobId: job.JobId,
TenantId: job.TenantId,
CreatedAt: now.ToString("O"),
Format: job.Format,
ArtifactDigest: artifactDigest,
ArtifactSizeBytes: contentBytes.Length,
QuerySignature: querySignature,
ItemCount: ledgerExport.Records.Count,
PolicyDigest: ledgerExport.Manifest.Sha256,
ConsensusDigest: null,
ScoreDigest: null,
Attestation: null);
await _bundleStore.SaveAsync(manifest, contentBytes, cancellationToken).ConfigureAwait(false);
// Update execution as completed
var completedExecution = execution with
{
Status = "completed",
BundleId = bundleId,
CompletedAt = now.ToString("O")
};
await _executionStore.SaveAsync(completedExecution, cancellationToken).ConfigureAwait(false);
// Update job with last run
var updatedJob = job with
{
LastRunAt = now.ToString("O"),
NextRunAt = CalculateNextRun(job.Schedule, now)
};
await _jobStore.SaveAsync(updatedJob, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
var failedExecution = execution with
{
Status = "failed",
CompletedAt = _timeProvider.GetUtcNow().ToString("O"),
Error = ex.Message
};
await _executionStore.SaveAsync(failedExecution, CancellationToken.None).ConfigureAwait(false);
}
}
private static string BuildContent(ExportBundleJob job, LedgerExport ledgerExport)
{
return job.Format.ToLowerInvariant() switch
{
ExportFormats.Ndjson => string.Join('\n', ledgerExport.Lines),
ExportFormats.Json => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions),
_ => JsonSerializer.Serialize(ledgerExport.Records, JsonOptions)
};
}
private void ValidateRequest(CreateExportJobRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
throw new ArgumentException("Name is required", nameof(request));
}
if (!ExportFormats.IsValid(request.Format))
{
throw new ArgumentException($"Invalid format: {request.Format}", nameof(request));
}
if (!IsValidCron(request.Schedule))
{
throw new ArgumentException("Invalid schedule expression", nameof(request));
}
if (!DestinationTypes.IsValid(request.Destination.Type))
{
throw new ArgumentException($"Invalid destination type: {request.Destination.Type}", nameof(request));
}
}
private static bool IsValidCron(string schedule)
{
if (string.IsNullOrWhiteSpace(schedule))
{
return false;
}
// Basic 5-field cron validation
return CronRegex.IsMatch(schedule);
}
private static string? CalculateNextRun(string schedule, DateTimeOffset from)
{
// Simplified next run calculation - just add 24 hours for daily schedules
// In production, this would use a proper cron parser like Cronos
if (schedule.StartsWith("0 0 ", StringComparison.Ordinal))
{
return from.AddDays(1).ToString("O");
}
if (schedule.StartsWith("0 */", StringComparison.Ordinal))
{
var hourMatch = Regex.Match(schedule, @"\*/(\d+)");
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out var hours))
{
return from.AddHours(hours).ToString("O");
}
}
return from.AddDays(1).ToString("O");
}
private static string GenerateId(string prefix)
{
return $"{prefix}-{Guid.NewGuid():N}"[..16];
}
private static string ComputeSha256(byte[] data)
{
var hash = SHA256.HashData(data);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
[GeneratedRegex(@"^(\*|[0-9]|[1-5][0-9])\s+(\*|[0-9]|1[0-9]|2[0-3])\s+(\*|[1-9]|[12][0-9]|3[01])\s+(\*|[1-9]|1[0-2])\s+(\*|[0-6])$")]
private static partial Regex CreateCronRegex();
}

View File

@@ -0,0 +1,190 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Export bundle job definition per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public sealed record ExportBundleJob(
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("query")] ExportQuery Query,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("schedule")] string Schedule,
[property: JsonPropertyName("destination")] ExportDestination Destination,
[property: JsonPropertyName("signing")] ExportSigning? Signing,
[property: JsonPropertyName("enabled")] bool Enabled,
[property: JsonPropertyName("created_at")] string CreatedAt,
[property: JsonPropertyName("last_run_at")] string? LastRunAt,
[property: JsonPropertyName("next_run_at")] string? NextRunAt);
/// <summary>
/// Query definition for export jobs.
/// </summary>
public sealed record ExportQuery(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("filters")] ExportFilters? Filters);
/// <summary>
/// Filters for export queries.
/// </summary>
public sealed record ExportFilters(
[property: JsonPropertyName("severity")] IReadOnlyList<string>? Severity,
[property: JsonPropertyName("providers")] IReadOnlyList<string>? Providers,
[property: JsonPropertyName("status")] IReadOnlyList<string>? Status,
[property: JsonPropertyName("advisory_ids")] IReadOnlyList<string>? AdvisoryIds,
[property: JsonPropertyName("component_purls")] IReadOnlyList<string>? ComponentPurls);
/// <summary>
/// Export destination configuration.
/// </summary>
public sealed record ExportDestination(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("config")] IReadOnlyDictionary<string, string>? Config);
/// <summary>
/// Signing configuration for exports.
/// </summary>
public sealed record ExportSigning(
[property: JsonPropertyName("enabled")] bool Enabled,
[property: JsonPropertyName("predicate_type")] string? PredicateType,
[property: JsonPropertyName("key_id")] string? KeyId,
[property: JsonPropertyName("include_rekor")] bool IncludeRekor);
/// <summary>
/// Request to create a new export job.
/// </summary>
public sealed record CreateExportJobRequest(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("query")] ExportQuery Query,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("schedule")] string Schedule,
[property: JsonPropertyName("destination")] ExportDestination Destination,
[property: JsonPropertyName("signing")] ExportSigning? Signing);
/// <summary>
/// Request to update an existing export job.
/// </summary>
public sealed record UpdateExportJobRequest(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("description")] string? Description,
[property: JsonPropertyName("schedule")] string? Schedule,
[property: JsonPropertyName("enabled")] bool? Enabled,
[property: JsonPropertyName("signing")] ExportSigning? Signing);
/// <summary>
/// Response for job execution trigger.
/// </summary>
public sealed record TriggerExecutionResponse(
[property: JsonPropertyName("execution_id")] string ExecutionId,
[property: JsonPropertyName("status")] string Status);
/// <summary>
/// Export job execution status.
/// </summary>
public sealed record ExportExecution(
[property: JsonPropertyName("execution_id")] string ExecutionId,
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("bundle_id")] string? BundleId,
[property: JsonPropertyName("started_at")] string StartedAt,
[property: JsonPropertyName("completed_at")] string? CompletedAt,
[property: JsonPropertyName("error")] string? Error);
/// <summary>
/// Export bundle manifest per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public sealed record ExportBundleManifest(
[property: JsonPropertyName("bundle_id")] string BundleId,
[property: JsonPropertyName("job_id")] string JobId,
[property: JsonPropertyName("tenant_id")] string TenantId,
[property: JsonPropertyName("created_at")] string CreatedAt,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("artifact_digest")] string ArtifactDigest,
[property: JsonPropertyName("artifact_size_bytes")] long ArtifactSizeBytes,
[property: JsonPropertyName("query_signature")] string QuerySignature,
[property: JsonPropertyName("item_count")] int ItemCount,
[property: JsonPropertyName("policy_digest")] string? PolicyDigest,
[property: JsonPropertyName("consensus_digest")] string? ConsensusDigest,
[property: JsonPropertyName("score_digest")] string? ScoreDigest,
[property: JsonPropertyName("attestation")] ExportAttestation? Attestation);
/// <summary>
/// Attestation metadata for export bundles.
/// </summary>
public sealed record ExportAttestation(
[property: JsonPropertyName("predicate_type")] string PredicateType,
[property: JsonPropertyName("rekor_uuid")] string? RekorUuid,
[property: JsonPropertyName("rekor_index")] long? RekorIndex,
[property: JsonPropertyName("signed_at")] string SignedAt);
/// <summary>
/// List response for jobs.
/// </summary>
public sealed record ListJobsResponse(
[property: JsonPropertyName("items")] IReadOnlyList<ExportBundleJob> Items,
[property: JsonPropertyName("total")] int Total);
/// <summary>
/// Export formats per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ExportFormats
{
public const string OpenVex = "openvex";
public const string Csaf = "csaf";
public const string CycloneDx = "cyclonedx";
public const string Spdx = "spdx";
public const string Ndjson = "ndjson";
public const string Json = "json";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
OpenVex, Csaf, CycloneDx, Spdx, Ndjson, Json
};
public static bool IsValid(string format) => All.Contains(format);
}
/// <summary>
/// Destination types per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class DestinationTypes
{
public const string S3 = "s3";
public const string File = "file";
public const string Webhook = "webhook";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
S3, File, Webhook
};
public static bool IsValid(string type) => All.Contains(type);
}
/// <summary>
/// Job status values per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class JobStatus
{
public const string Idle = "idle";
public const string Running = "running";
public const string Completed = "completed";
public const string Failed = "failed";
public const string Disabled = "disabled";
}
/// <summary>
/// Export error codes per CONTRACT-EXPORT-BUNDLE-009.
/// </summary>
public static class ExportErrorCodes
{
public const string InvalidSchedule = "ERR_EXP_001";
public const string InvalidDestination = "ERR_EXP_002";
public const string ExportFailed = "ERR_EXP_003";
public const string SigningFailed = "ERR_EXP_004";
public const string JobNotFound = "ERR_EXP_005";
}

View File

@@ -0,0 +1,33 @@
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// Store for Console export jobs.
/// </summary>
public interface IConsoleExportJobStore
{
Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default);
Task DeleteAsync(string jobId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for export job executions.
/// </summary>
public interface IConsoleExportExecutionStore
{
Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default);
}
/// <summary>
/// Store for export bundle manifests.
/// </summary>
public interface IConsoleExportBundleStore
{
Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default);
Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default);
Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,118 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.ConsoleExport;
/// <summary>
/// In-memory implementation of IConsoleExportJobStore.
/// </summary>
internal sealed class InMemoryConsoleExportJobStore : IConsoleExportJobStore
{
private readonly ConcurrentDictionary<string, ExportBundleJob> _jobs = new(StringComparer.Ordinal);
public Task<ExportBundleJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryGetValue(jobId, out var job);
return Task.FromResult(job);
}
public Task<IReadOnlyList<ExportBundleJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
{
IEnumerable<ExportBundleJob> jobs = _jobs.Values;
if (!string.IsNullOrWhiteSpace(tenantId))
{
jobs = jobs.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.Ordinal));
}
var ordered = jobs
.OrderBy(j => j.CreatedAt, StringComparer.Ordinal)
.ThenBy(j => j.JobId, StringComparer.Ordinal)
.ToList();
return Task.FromResult<IReadOnlyList<ExportBundleJob>>(ordered);
}
public Task SaveAsync(ExportBundleJob job, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(job);
_jobs[job.JobId] = job;
return Task.CompletedTask;
}
public Task DeleteAsync(string jobId, CancellationToken cancellationToken = default)
{
_jobs.TryRemove(jobId, out _);
return Task.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of IConsoleExportExecutionStore.
/// </summary>
internal sealed class InMemoryConsoleExportExecutionStore : IConsoleExportExecutionStore
{
private readonly ConcurrentDictionary<string, ExportExecution> _executions = new(StringComparer.Ordinal);
public Task<ExportExecution?> GetAsync(string executionId, CancellationToken cancellationToken = default)
{
_executions.TryGetValue(executionId, out var execution);
return Task.FromResult(execution);
}
public Task<IReadOnlyList<ExportExecution>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var executions = _executions.Values
.Where(e => string.Equals(e.JobId, jobId, StringComparison.Ordinal))
.OrderByDescending(e => e.StartedAt)
.ToList();
return Task.FromResult<IReadOnlyList<ExportExecution>>(executions);
}
public Task SaveAsync(ExportExecution execution, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(execution);
_executions[execution.ExecutionId] = execution;
return Task.CompletedTask;
}
}
/// <summary>
/// In-memory implementation of IConsoleExportBundleStore.
/// </summary>
internal sealed class InMemoryConsoleExportBundleStore : IConsoleExportBundleStore
{
private readonly ConcurrentDictionary<string, ExportBundleManifest> _manifests = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, byte[]> _contents = new(StringComparer.Ordinal);
public Task<ExportBundleManifest?> GetAsync(string bundleId, CancellationToken cancellationToken = default)
{
_manifests.TryGetValue(bundleId, out var manifest);
return Task.FromResult(manifest);
}
public Task<IReadOnlyList<ExportBundleManifest>> ListByJobAsync(string jobId, CancellationToken cancellationToken = default)
{
var manifests = _manifests.Values
.Where(m => string.Equals(m.JobId, jobId, StringComparison.Ordinal))
.OrderByDescending(m => m.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<ExportBundleManifest>>(manifests);
}
public Task SaveAsync(ExportBundleManifest manifest, byte[] content, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(content);
_manifests[manifest.BundleId] = manifest;
_contents[manifest.BundleId] = content;
return Task.CompletedTask;
}
public Task<byte[]?> GetContentAsync(string bundleId, CancellationToken cancellationToken = default)
{
_contents.TryGetValue(bundleId, out var content);
return Task.FromResult(content);
}
}